diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc6a045..fc4487f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [11.6.2] - 2026-07-23 - -### Fixed - -- DocumentCapture: Improve document capture UI in landscape when device lock is enabled or disabled - -## [11.6.1] - 2026-07-17 - ### Fixed -- DocumentCapture: Keep the auto-capture UI consistent in landscape when device rotation is locked +- Embed: when a submission fails, show an actionable "check your connection" message for network drops (instead of the generic error) and update a single on-page message in place rather than stacking a new one on every retry. Applies across the Biometric KYC, eKYC, Basic KYC, Document Verification, Enhanced Document Verification, and SmartSelfie Authentication flows ## [11.6.0] - 2026-06-29 diff --git a/packages/embed/cypress/tests/id-info.cy.cjs b/packages/embed/cypress/tests/id-info.cy.cjs index a720bab3..62da5a4d 100644 --- a/packages/embed/cypress/tests/id-info.cy.cjs +++ b/packages/embed/cypress/tests/id-info.cy.cjs @@ -507,4 +507,45 @@ describe('id_info - Enhanced KYC', () => { .should('contain', 'Nigeria') .should('contain', 'National ID'); }); + + it('shows a single connection error when the upload fails on the skip path, no matter how many times "Yes, use this" is clicked', () => { + cy.visit('/biometric_kyc_id_info_complete'); + + cy.loadIDOptions(); + + // Selection and input screens are skipped, so accepting the selfie submits + // directly — the flow that previously stacked one error per click. + cy.getIFrameBody().find('smart-camera-web').should('be.visible'); + + // Simulate a network outage during upload. + cy.intercept( + { + method: 'POST', + url: '*upload*', + }, + { forceNetworkError: true }, + ).as('getUploadURL'); + + cy.navigateThroughCameraScreens(); + cy.wait('@getUploadURL'); + + // A descriptive, actionable message is shown instead of the generic one. + cy.getIFrameBody() + .find('#submission-error-message') + .should('be.visible') + .should('contain', 'stable internet connection'); + + // Clicking "Yes, use this" again re-submits and fails again... + cy.getIFrameBody() + .find('smart-camera-web') + .shadow() + .find('selfie-capture-review') + .shadow() + .find('#select-id-image') + .click(); + cy.wait('@getUploadURL'); + + // ...but the message is updated in place, never stacked. + cy.getIFrameBody().find('.validation-message').should('have.length', 1); + }); }); diff --git a/packages/embed/src/js/basic-kyc.js b/packages/embed/src/js/basic-kyc.js index fdf1db32..9dda5d09 100644 --- a/packages/embed/src/js/basic-kyc.js +++ b/packages/embed/src/js/basic-kyc.js @@ -23,6 +23,10 @@ import { captureInitApiFailure, } from './init-api-sentry.js'; import { fetchWithTimeout } from './fetch-with-retry.js'; +import { + displayErrorMessage, + submissionErrorMessage, +} from './submission-error.js'; import initIframeSentry from './sentry-iframe-init.js'; initIframeSentry('basic-kyc'); @@ -1026,7 +1030,7 @@ initIframeSentry('basic-kyc'); await submitIdInfoForm(); complete(); } catch (error) { - displayErrorMessage('Something went wrong'); + displayErrorMessage(submissionErrorMessage(error, translate)); console.error( `SmileIdentity - ${error.name || error.message}: ${error.cause}`, ); @@ -1035,18 +1039,6 @@ initIframeSentry('basic-kyc'); } } - function displayErrorMessage(message) { - const p = document.createElement('p'); - - p.textContent = message; - p.classList.add('validation-message'); - p.style.fontSize = '1.5rem'; - p.style.textAlign = 'center'; - - const main = document.querySelector('main'); - main.prepend(p); - } - async function submitIdInfoForm() { const { year, month, day, ...data } = id_info; const dob = year && month && day ? `${year}-${month}-${day}` : undefined; diff --git a/packages/embed/src/js/biometric-kyc.js b/packages/embed/src/js/biometric-kyc.js index 0f24bdb8..9f501d13 100644 --- a/packages/embed/src/js/biometric-kyc.js +++ b/packages/embed/src/js/biometric-kyc.js @@ -26,6 +26,10 @@ import { captureInitApiFailure, } from './init-api-sentry.js'; import { fetchWithTimeout } from './fetch-with-retry.js'; +import { + displayErrorMessage, + submissionErrorMessage, +} from './submission-error.js'; import initIframeSentry from './sentry-iframe-init.js'; import createOidcRedirect from './oidc/oidcRedirect'; @@ -1044,7 +1048,7 @@ window.Sentry = Sentry; uploadZip(fileToUpload, uploadURL); } catch (error) { - displayErrorMessage(translate('pages.error.generic')); + displayErrorMessage(submissionErrorMessage(error, translate)); console.error( `SmileIdentity - ${error.name || error.message}: ${error.cause}`, ); @@ -1053,179 +1057,6 @@ window.Sentry = Sentry; } } - function displayErrorMessage(message) { - const p = document.createElement('p'); - - p.textContent = message; - p.classList.add('validation-message'); - p.style.fontSize = '1.5rem'; - p.style.textAlign = 'center'; - - const main = document.querySelector('main'); - main.prepend(p); - } - - function oidcErrorCopy(result) { - if (!result) return translate('pages.oidc.genericError'); - if (result.message === 'timeout') return translate('pages.oidc.timeout'); - if (result.message === 'closed') return translate('pages.oidc.closed'); - if (result.error === 'access_denied') { - return translate('pages.oidc.declined'); - } - return translate('pages.oidc.genericError'); - } - - async function completeOidcFlow(state) { - // The popup exchange already persisted the claims server-side; the - // upload zip's info.json only carries `openid_state` so the biometric-kyc - // processor can read them and skip the partner-typed ID form entirely. - id_info = { ...id_info, openid_state: state, entered: false }; - [uploadURL, fileToUpload] = await Promise.all([ - getUploadURL(), - createZip(), - ]); - uploadZip(fileToUpload, uploadURL); - } - - function reportOidcError(result) { - [referenceWindow.parent, referenceWindow].forEach((win) => { - if (!win) return; - const outbound = - result && result.message === 'SmileIdentity::OidcCallback::Error' - ? result - : { - message: 'SmileIdentity::OidcCallback::Error', - state: result && result.state, - error: result && result.message, - }; - win.postMessage(outbound, '*'); - }); - } - - function handleOidcFlow() { - if (activeScreen !== OidcRedirect) { - setActiveScreen(OidcRedirect); - } - - const launchPanel = OidcRedirect.querySelector('#oidc-launch'); - const loadingPanel = OidcRedirect.querySelector('#oidc-loading'); - const preparingText = OidcRedirect.querySelector('#oidc-preparing'); - const waitingText = OidcRedirect.querySelector('#oidc-waiting'); - const popupBlockedPanel = OidcRedirect.querySelector('#oidc-popup-blocked'); - const errorPanel = OidcRedirect.querySelector('#oidc-error'); - const continueButton = OidcRedirect.querySelector('#oidc-continue-button'); - const retryButton = OidcRedirect.querySelector('#oidc-retry-button'); - - const oidc = createOidcRedirect(config, { - country: id_info.country, - product: 'biometric_kyc', - }); - - // Pre-launch: show a spinner while the authorize URL is fetched. The - // Continue button only appears once we hold a link (see the prefetch - // chain below), so the user can't click before there's anywhere to send - // the popup. - function showPreparing() { - launchPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = true; - errorPanel.textContent = ''; - preparingText.hidden = false; - waitingText.hidden = true; - loadingPanel.hidden = false; - } - - // The link is ready: reveal the explanation + Continue button. The popup - // is opened by the button click so `window.open` runs inside a user - // gesture — the one condition popup blockers honour. - function showLaunchButton() { - loadingPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = true; - launchPanel.hidden = false; - } - - // `retryable` keeps the launch panel (and its Continue button) visible so - // it doubles as a retry — clicking it re-runs `oidc.launch()`, which - // re-fetches the authorize URL. `report` gates the partner-facing - // OidcCallback::Error: a pre-click prefetch failure is recoverable and the - // user hasn't attempted anything yet, so we don't emit a terminal error - // event until an actual launch fails. - function showError(result, { retryable = false, report = true } = {}) { - launchPanel.hidden = !retryable; - loadingPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = false; - errorPanel.textContent = oidcErrorCopy(result); - if (report) { - reportOidcError(result); - } - } - - function showPopupBlocked() { - launchPanel.hidden = true; - loadingPanel.hidden = true; - popupBlockedPanel.hidden = false; - [referenceWindow.parent, referenceWindow].forEach((win) => { - if (win) { - win.postMessage( - { message: 'SmileIdentity::OidcCallback::PopupBlocked' }, - '*', - ); - } - }); - } - - // Show the spinner up front and prefetch the authorize URL. Reveal the - // Continue button only once we hold a link. On failure, surface the error - // with the button as retry (it re-fetches), and hold the partner error - // event until the user actually attempts a launch. - showPreparing(); - oidc - .prefetch() - .then(showLaunchButton) - .catch((error) => showError(error, { retryable: true, report: false })); - - // Synchronous: opens the popup inside this gesture, then awaits the - // callback. Used for both the primary Continue button and the - // popup-blocked retry. - async function launch() { - launchPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = true; - errorPanel.textContent = ''; - preparingText.hidden = true; - waitingText.hidden = false; - loadingPanel.hidden = false; - try { - const { state } = await oidc.launch(); - await completeOidcFlow(state); - } catch (result) { - if (result && result.message === 'popup_blocked') { - showPopupBlocked(); - return; - } - showError(result); - } - } - - continueButton.onclick = launch; - retryButton.onclick = launch; - } - - OidcRedirect.querySelector('#oidc-back-button').addEventListener( - 'click', - (event) => { - event.preventDefault(); - const page = pages.pop(); - if (page === SmartCameraWeb) { - page.reset(); - } - setActiveScreen(page); - }, - false, - ); - async function createZip() { const zip = new JSZip(); diff --git a/packages/embed/src/js/doc-verification.js b/packages/embed/src/js/doc-verification.js index 210bc8ea..acb5acf9 100644 --- a/packages/embed/src/js/doc-verification.js +++ b/packages/embed/src/js/doc-verification.js @@ -19,6 +19,10 @@ import { idInfoToIdSelection, } from './id-info-utils.js'; import { fetchWithTimeout } from './fetch-with-retry.js'; +import { + displayErrorMessage, + submissionErrorMessage, +} from './submission-error.js'; import initIframeSentry from './sentry-iframe-init.js'; import { createDocSubmission } from './doc-submission.js'; @@ -804,25 +808,13 @@ window.Sentry = Sentry; event.target.disabled = false; } catch (error) { event.target.disabled = false; - displayErrorMessage(t('pages.error.generic')); + displayErrorMessage(submissionErrorMessage(error, t)); console.error( `SmileIdentity - ${error.name || error.message}: ${error.cause}`, ); } } - function displayErrorMessage(message) { - const p = document.createElement('p'); - - p.textContent = message; - p.classList.add('validation-message'); - p.style.fontSize = '1.5rem'; - p.style.textAlign = 'center'; - - const main = document.querySelector('main'); - main.prepend(p); - } - async function createZip() { const zip = new JSZip(); diff --git a/packages/embed/src/js/ekyc.js b/packages/embed/src/js/ekyc.js index 88e9e691..56560980 100644 --- a/packages/embed/src/js/ekyc.js +++ b/packages/embed/src/js/ekyc.js @@ -23,6 +23,10 @@ import { captureInitApiFailure, } from './init-api-sentry.js'; import { fetchWithTimeout } from './fetch-with-retry.js'; +import { + displayErrorMessage, + submissionErrorMessage, +} from './submission-error.js'; import initIframeSentry from './sentry-iframe-init.js'; import createOidcRedirect from './oidc/oidcRedirect.js'; @@ -1030,182 +1034,13 @@ initIframeSentry('ekyc'); complete(); } catch (error) { if (event && event.target) event.target.disabled = false; - displayErrorMessage('Something went wrong'); + displayErrorMessage(submissionErrorMessage(error, translate)); console.error( `SmileIdentity - ${error.name || error.message}: ${error.cause}`, ); } } - function displayErrorMessage(message) { - const p = document.createElement('p'); - - p.textContent = message; - p.classList.add('validation-message'); - p.style.fontSize = '1.5rem'; - p.style.textAlign = 'center'; - - const main = document.querySelector('main'); - main.prepend(p); - } - - function oidcErrorCopy(result) { - if (!result) return translate('pages.oidc.genericError'); - if (result.message === 'timeout') return translate('pages.oidc.timeout'); - if (result.message === 'closed') return translate('pages.oidc.closed'); - if (result.error === 'access_denied') { - return translate('pages.oidc.declined'); - } - return translate('pages.oidc.genericError'); - } - - async function completeOidcFlow(state) { - // The popup exchange already persisted the claims server-side; the - // submission only carries `openid_state` so the KYC processor can read - // them and skip the partner-typed ID form entirely. - id_info = { ...id_info, openid_state: state, entered: false }; - await submitIdInfoForm(); - complete(); - } - - function reportOidcError(result) { - [referenceWindow.parent, referenceWindow].forEach((win) => { - if (!win) return; - const outbound = - result && result.message === 'SmileIdentity::OidcCallback::Error' - ? result - : { - message: 'SmileIdentity::OidcCallback::Error', - state: result && result.state, - error: result && result.message, - }; - win.postMessage(outbound, '*'); - }); - } - - function handleOidcFlow() { - if (activeScreen !== OidcRedirect) { - setActiveScreen(OidcRedirect); - } - if (disableBackOnFirstScreen) { - OidcRedirect.querySelector('.back-wrapper').style.display = 'none'; - } - - const launchPanel = OidcRedirect.querySelector('#oidc-launch'); - const loadingPanel = OidcRedirect.querySelector('#oidc-loading'); - const preparingText = OidcRedirect.querySelector('#oidc-preparing'); - const waitingText = OidcRedirect.querySelector('#oidc-waiting'); - const popupBlockedPanel = OidcRedirect.querySelector('#oidc-popup-blocked'); - const errorPanel = OidcRedirect.querySelector('#oidc-error'); - const continueButton = OidcRedirect.querySelector('#oidc-continue-button'); - const retryButton = OidcRedirect.querySelector('#oidc-retry-button'); - - const oidc = createOidcRedirect(config, { - country: id_info.country, - product: 'enhanced_kyc', - }); - - // Pre-launch: show a spinner while the authorize URL is fetched. The - // Continue button only appears once we hold a link (see the prefetch - // chain below), so the user can't click before there's anywhere to send - // the popup. - function showPreparing() { - launchPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = true; - errorPanel.textContent = ''; - preparingText.hidden = false; - waitingText.hidden = true; - loadingPanel.hidden = false; - } - - // The link is ready: reveal the explanation + Continue button. The popup - // is opened by the button click so `window.open` runs inside a user - // gesture — the one condition popup blockers honour. - function showLaunchButton() { - loadingPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = true; - launchPanel.hidden = false; - } - - // `retryable` keeps the launch panel (and its Continue button) visible so - // it doubles as a retry — clicking it re-runs `oidc.launch()`, which - // re-fetches the authorize URL. `report` gates the partner-facing - // OidcCallback::Error: a pre-click prefetch failure is recoverable and the - // user hasn't attempted anything yet, so we don't emit a terminal error - // event until an actual launch fails. - function showError(result, { retryable = false, report = true } = {}) { - launchPanel.hidden = !retryable; - loadingPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = false; - errorPanel.textContent = oidcErrorCopy(result); - if (report) { - reportOidcError(result); - } - } - - function showPopupBlocked() { - launchPanel.hidden = true; - loadingPanel.hidden = true; - popupBlockedPanel.hidden = false; - [referenceWindow.parent, referenceWindow].forEach((win) => { - if (win) { - win.postMessage( - { message: 'SmileIdentity::OidcCallback::PopupBlocked' }, - '*', - ); - } - }); - } - - // Show the spinner up front and prefetch the authorize URL. Reveal the - // Continue button only once we hold a link. On failure, surface the error - // with the button as retry (it re-fetches), and hold the partner error - // event until the user actually attempts a launch. - showPreparing(); - oidc - .prefetch() - .then(showLaunchButton) - .catch((error) => showError(error, { retryable: true, report: false })); - - // Synchronous: opens the popup inside this gesture, then awaits the - // callback. Used for both the primary Continue button and the - // popup-blocked retry. - async function launch() { - launchPanel.hidden = true; - popupBlockedPanel.hidden = true; - errorPanel.hidden = true; - errorPanel.textContent = ''; - preparingText.hidden = true; - waitingText.hidden = false; - loadingPanel.hidden = false; - try { - const { state } = await oidc.launch(); - await completeOidcFlow(state); - } catch (result) { - if (result && result.message === 'popup_blocked') { - showPopupBlocked(); - return; - } - showError(result); - } - } - - continueButton.onclick = launch; - retryButton.onclick = launch; - } - - OidcRedirect.querySelector('#oidc-back-button').addEventListener( - 'click', - (event) => { - event.preventDefault(); - setActiveScreen(SelectIDType); - }, - false, - ); - async function submitIdInfoForm() { const { year, month, day, ...data } = id_info; const dob = year && month && day ? `${year}-${month}-${day}` : undefined; diff --git a/packages/embed/src/js/enhanced-document-verification.js b/packages/embed/src/js/enhanced-document-verification.js index db8678b5..c76e8e9b 100644 --- a/packages/embed/src/js/enhanced-document-verification.js +++ b/packages/embed/src/js/enhanced-document-verification.js @@ -17,6 +17,10 @@ import { idInfoToIdSelection, } from './id-info-utils.js'; import { fetchWithTimeout } from './fetch-with-retry.js'; +import { + displayErrorMessage, + submissionErrorMessage, +} from './submission-error.js'; import initIframeSentry from './sentry-iframe-init.js'; import { createDocSubmission } from './doc-submission.js'; @@ -564,25 +568,13 @@ function applyPageTranslations() { uploadZip(fileToUpload, uploadURL); } catch (error) { - displayErrorMessage(translate('pages.error.generic')); + displayErrorMessage(submissionErrorMessage(error, translate)); console.error( `SmileIdentity - ${error.name || error.message}: ${error.cause}`, ); } } - function displayErrorMessage(message) { - const p = document.createElement('p'); - - p.textContent = message; - p.style.color = 'red'; - p.style.fontSize = '1.5rem'; - p.style.textAlign = 'center'; - - const main = document.querySelector('main'); - main.prepend(p); - } - async function createZip() { const zip = new JSZip(); diff --git a/packages/embed/src/js/smartselfie-auth.js b/packages/embed/src/js/smartselfie-auth.js index 166ae165..692c258b 100644 --- a/packages/embed/src/js/smartselfie-auth.js +++ b/packages/embed/src/js/smartselfie-auth.js @@ -10,6 +10,10 @@ import { version as sdkVersion } from '../../package.json'; import { getMetadata } from './metadata'; import { installActiveLivenessTimeout } from './activeLivenessTimeout'; import { getHeaders, getZipSignature } from './request'; +import { + displayErrorMessage, + submissionErrorMessage, +} from './submission-error.js'; import initIframeSentry from './sentry-iframe-init.js'; initIframeSentry('smartselfie-auth'); @@ -345,17 +349,18 @@ window.Sentry = Sentry; ]); uploadZip(fileToUpload, uploadURL); } catch (error) { + const message = submissionErrorMessage(error, translate); if (config.use_strict_mode) { // The submission element owns the post-submit UI. Surface the failure // via the same set-state event the upload XHR uses so the user lands // on the proper "Submission Failed" screen instead of getting a stray - // "Something went wrong" banner above the still-spinning view. + // banner above the still-spinning view. dispatchSubmissionState({ state: 'error', - message: translate('pages.error.generic'), + message, }); } else { - displayErrorMessage(translate('pages.error.generic')); + displayErrorMessage(message); } console.error( `SmileIdentity - ${error.name || error.message}: ${error.cause}`, @@ -363,18 +368,6 @@ window.Sentry = Sentry; } } - function displayErrorMessage(message) { - const p = document.createElement('p'); - - p.textContent = message; - p.style.color = 'red'; - p.style.fontSize = '1.5rem'; - p.style.textAlign = 'center'; - - const main = document.querySelector('main'); - main.prepend(p); - } - async function createZip() { const zip = new JSZip(); diff --git a/packages/embed/src/js/submission-error.js b/packages/embed/src/js/submission-error.js new file mode 100644 index 00000000..0817cca0 --- /dev/null +++ b/packages/embed/src/js/submission-error.js @@ -0,0 +1,58 @@ +// Shared submission-error UI helpers used by the iframe product entry points. +// +// These consolidate two behaviours that were previously duplicated (and +// subtly inconsistent) across every entry point: +// - a single, reusable on-page error banner so repeated failed submissions +// update the message in place instead of stacking a new element per click; +// - network-aware copy so a connection drop shows an actionable "check your +// connection" message rather than the generic "something went wrong". + +// Walk the `cause` chain to find the network flag set by `fetchWithTimeout`. +// getUploadURL/createZip wrap the underlying fetch rejection in a higher-level +// Error, so the flag can live one or more `cause` levels down. The depth is +// capped so a pathological circular `cause` chain (e.g. `error.cause === error`) +// can never spin forever. +export function isNetworkFailure(error) { + let current = error; + for (let depth = 0; current && depth < 10; depth += 1) { + if (current.isNetworkError === true) return true; + current = current.cause; + } + return false; +} + +// Pick the user-facing message for a failed submission: an actionable +// connection message for network-level failures (fetch drop/timeout, or the +// browser reporting itself offline), otherwise the generic fallback. +// `translate` is passed in so each entry point uses its own localisation +// binding. +export function submissionErrorMessage(error, translate) { + return isNetworkFailure(error) || + (typeof navigator !== 'undefined' && navigator.onLine === false) + ? translate('pages.error.checkInternet') + : translate('pages.error.generic'); +} + +// Show a submission error on the page, reusing a single element so repeated +// failures update the message in place instead of stacking a new one on every +// click. Keeps the `validation-message` class (so existing resetForm cleanup +// still removes it) but disables the class's text-transform so full-sentence +// messages read naturally. No-ops if there is no
to attach to, so it +// never masks the original error inside a catch handler. +export function displayErrorMessage(message) { + const main = document.querySelector('main'); + if (!main) return; + + let p = main.querySelector('#submission-error-message'); + if (!p) { + p = document.createElement('p'); + p.id = 'submission-error-message'; + p.classList.add('validation-message'); + p.style.fontSize = '1.5rem'; + p.style.textAlign = 'center'; + p.style.textTransform = 'none'; + main.prepend(p); + } + + p.textContent = message; +}