From e4df66f0f70b78acf57a976cb71c70b25076191c Mon Sep 17 00:00:00 2001 From: Alex Babrykovich Date: Sun, 6 Oct 2024 21:20:42 +0200 Subject: [PATCH] feat: add orientation param #62 (#63) * feat: add orientation param #62 * feat: expose reset into even object to allow retry #62 * feat: emit expired after 120 sec if token is not markUsed #62 * docs: update README.md #62 * docs: add orientatin param to table and markUsed snippet --- Example.App.js | 11 +++-- Hcaptcha.d.ts | 5 +++ Hcaptcha.js | 31 ++++++++++--- README.md | 43 +++++++++++++++---- .../ConfirmHcaptcha.test.js.snap | 6 ++- __tests__/__snapshots__/Hcaptcha.test.js.snap | 9 ++-- index.js | 3 ++ 7 files changed, 86 insertions(+), 22 deletions(-) diff --git a/Example.App.js b/Example.App.js index 36c6954..04ea0f1 100644 --- a/Example.App.js +++ b/Example.App.js @@ -14,15 +14,20 @@ export default class App extends React.Component { if (event && event.nativeEvent.data) { if (['cancel'].includes(event.nativeEvent.data)) { this.captchaForm.hide(); - this.setState({ code: event.nativeEvent.data}); - } else if (['error', 'expired'].includes(event.nativeEvent.data)) { + this.setState({ code: event.nativeEvent.data }); + } else if (['error'].includes(event.nativeEvent.data)) { this.captchaForm.hide(); - this.setState({ code: event.nativeEvent.data}); + this.setState({ code: event.nativeEvent.data }); + console.log('Verification failed', event.nativeEvent.data); + } else if (event.nativeEvent.data === 'expired') { + event.reset(); + console.log('Visual challenge expired, reset...', event.nativeEvent.data); } else if (event.nativeEvent.data === 'open') { console.log('Visual challenge opened'); } else { console.log('Verified code from hCaptcha', event.nativeEvent.data); this.captchaForm.hide(); + event.markUsed(); this.setState({ code: event.nativeEvent.data }); } } diff --git a/Hcaptcha.d.ts b/Hcaptcha.d.ts index fa7a414..e4bc0d5 100644 --- a/Hcaptcha.d.ts +++ b/Hcaptcha.d.ts @@ -81,6 +81,11 @@ type HcaptchaProps = { * hCaptcha SDK host identifier. null value means that it will be generated by SDK */ host?: string; + /** + * The orientation of the challenge. + * Default: portrait + */ + orientation?: 'portrait' | 'landscape'; } export default class Hcaptcha extends React.Component {} diff --git a/Hcaptcha.js b/Hcaptcha.js index f1e4475..6b8463e 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useRef } from 'react'; import WebView from 'react-native-webview'; import { Linking, StyleSheet, View, ActivityIndicator } from 'react-native'; import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; @@ -20,7 +20,7 @@ const patchPostMessageJsCode = `(${String(function () { window.ReactNativeWebView.postMessage = patchedPostMessage; })})();`; -const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, assethost, imghost, reportapi) => { +const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation) => { var url = `${jsSrc || "https://hcaptcha.com/1/api.js"}?render=explicit&onload=onloadCallback`; let effectiveHost; @@ -30,7 +30,7 @@ const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, host = (siteKey || 'missing-sitekey') + '.react-native.hcaptcha.com'; } - for (let [key, value] of Object.entries({ host, hl, custom: typeof theme === 'object', sentry, endpoint, assethost, imghost, reportapi })) { + for (let [key, value] of Object.entries({ host, hl, custom: typeof theme === 'object', sentry, endpoint, assethost, imghost, reportapi, orientation })) { if (value) { url += `&${key}=${encodeURIComponent(value)}` } @@ -60,6 +60,7 @@ const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, * @param {string} imghost: Points loaded hCaptcha challenge images to a user defined image location, used for proxies. Default: https://imgs.hcaptcha.com (Override only if using first-party hosting feature.) * @param {string} host: hCaptcha SDK host identifier. null value means that it will be generated by SDK * @param {object} debug: debug information + * @parem {string} hCaptcha challenge orientation */ const Hcaptcha = ({ onMessage, @@ -81,8 +82,10 @@ const Hcaptcha = ({ imghost, host, debug, + orientation, }) => { - const apiUrl = buildHcaptchaApiUrl(jsSrc, siteKey, languageCode, theme, host, sentry, endpoint, assethost, imghost, reportapi); + const apiUrl = buildHcaptchaApiUrl(jsSrc, siteKey, languageCode, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation); + const tokenTimeout = 120000; if (theme && typeof theme === 'string') { theme = `"${theme}"`; @@ -151,7 +154,7 @@ const Hcaptcha = ({ console.log("challenge opened"); }; var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; - var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("cancel"); }; + var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; var onDataErrorCallback = function(error) { console.log("challenge error callback fired"); window.ReactNativeWebView.postMessage("error"); @@ -199,8 +202,17 @@ const Hcaptcha = ({ [loadingIndicatorColor] ); + const webViewRef = useRef(null); + + const reset = () => { + if (webViewRef.current) { + webViewRef.current.injectJavaScript('onloadCallback();'); + } + }; + return ( { if (event.url.slice(0, 24) === 'https://www.hcaptcha.com') { @@ -210,7 +222,14 @@ const Hcaptcha = ({ return true; }} mixedContentMode={'always'} - onMessage={onMessage} + onMessage={(e) => { + e.reset = reset; + if (e.nativeEvent.data.length > 16) { + const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, reset }), tokenTimeout); + e.markUsed = () => clearTimeout(expiredTokenTimerId); + } + onMessage(e); + }} javaScriptEnabled injectedJavaScript={patchPostMessageJsCode} automaticallyAdjustContentInsets diff --git a/README.md b/README.md index 72881c4..f0c30ec 100644 --- a/README.md +++ b/README.md @@ -35,28 +35,54 @@ Also, please note the following special message strings that can be returned via | name | purpose | | --- | --- | -| expired | passcode response expired and the user must re-verify | +| expired | passcode response expired and the user must re-verify, or did not answer before session expired | | error | there was an error displaying the challenge | -| cancel | the user closed the challenge, or did not answer before session expired | +| cancel | the user closed the challenge | | open | the visual challenge was opened | Any other string returned by `onMessage` will be a passcode. + ### Handling the post-issuance expiration lifecycle This extension is a lightweight wrapper, and does not currently attempt to manage post-verification state in the same way as the web JS API, e.g. with an on-expire callback. In particular, if you do **not** plan to immediately consume the passcode returned by submitting it to your backend, you should start a timer to let your application state know that a new passcode is required when it expires. -By default, this value is 120 seconds. Thus, you would want code similar to the following in your app when handling `onMessage` responses that return a passcode: - -``` -this.timeoutCheck = setTimeout(() => { - this.setPasscodeExpired(); - }, 120000); +By default, this value is 120 seconds. So, an `expired` error will be emitted to `onMessage` if you haven't called `event.markUsed()`. + +Once you've utilized hCaptcha's token, call `markUsed` on the event object in `onMessage`: + +```js + onMessage = event => { + if (event && event.nativeEvent.data) { + if (['cancel'].includes(event.nativeEvent.data)) { + this.captchaForm.hide(); + } else if (['error'].includes(event.nativeEvent.data)) { + this.captchaForm.hide(); + // handle error + } else { + this.captchaForm.hide(); + const token = event.nativeEvent.data; + // utlize token and call markUsed once you done with it + event.markUsed(); + } + } + }; + ... + (this.captchaForm = _ref)} + siteKey={siteKey} + languageCode="en" + onMessage={this.onMessage} + /> ``` +### Handling errors and retry + +If your app encounters an `error` event, you can reset the hCaptcha SDK flow by calling `event.reset()`` to perform another attempt at verification. + ## Dependencies 1. [react-native-modal](https://github.com/react-native-community/react-native-modal) @@ -129,6 +155,7 @@ Otherwise, you should pass in the preferred device locale, e.g. fetched from `ge | baseUrl _(modal component only)_ | string | The url domain defined on your hCaptcha. You generally will not need to change this. | | passiveSiteKey _(modal component only)_ | boolean | Indicates whether the passive mode is enabled; when true, the modal won't be shown at all | | hasBackdrop _(modal component only)_ | boolean | Defines if the modal backdrop is shown (true by default) | +| orientation | string | This specifies the "orientation" of the challenge. It can be `portrait`, `landscape`. Default: `portrait` | ## Status diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index 0df6a2a..ea8b473 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -53,6 +53,7 @@ exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha with all props 1 })();" javaScriptEnabled={true} mixedContentMode="always" + onMessage={[Function]} onShouldStartLoadWithRequest={[Function]} originWhitelist={ [ @@ -103,7 +104,7 @@ exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha with all props 1 console.log("challenge opened"); }; var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; - var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("cancel"); }; + var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; var onDataErrorCallback = function(error) { console.log("challenge error callback fired"); window.ReactNativeWebView.postMessage("error"); @@ -208,6 +209,7 @@ exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha with minimum pro })();" javaScriptEnabled={true} mixedContentMode="always" + onMessage={[Function]} onShouldStartLoadWithRequest={[Function]} originWhitelist={ [ @@ -258,7 +260,7 @@ exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha with minimum pro console.log("challenge opened"); }; var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; - var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("cancel"); }; + var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; var onDataErrorCallback = function(error) { console.log("challenge error callback fired"); window.ReactNativeWebView.postMessage("error"); diff --git a/__tests__/__snapshots__/Hcaptcha.test.js.snap b/__tests__/__snapshots__/Hcaptcha.test.js.snap index d001e9e..09e3405 100644 --- a/__tests__/__snapshots__/Hcaptcha.test.js.snap +++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap @@ -15,6 +15,7 @@ exports[`Hcaptcha snapshot tests renders Hcaptcha with all props 1`] = ` })();" javaScriptEnabled={true} mixedContentMode="always" + onMessage={[Function]} onShouldStartLoadWithRequest={[Function]} originWhitelist={ [ @@ -65,7 +66,7 @@ exports[`Hcaptcha snapshot tests renders Hcaptcha with all props 1`] = ` console.log("challenge opened"); }; var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; - var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("cancel"); }; + var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; var onDataErrorCallback = function(error) { console.log("challenge error callback fired"); window.ReactNativeWebView.postMessage("error"); @@ -130,6 +131,7 @@ exports[`Hcaptcha snapshot tests renders Hcaptcha with minimum props 1`] = ` })();" javaScriptEnabled={true} mixedContentMode="always" + onMessage={[Function]} onShouldStartLoadWithRequest={[Function]} originWhitelist={ [ @@ -180,7 +182,7 @@ exports[`Hcaptcha snapshot tests renders Hcaptcha with minimum props 1`] = ` console.log("challenge opened"); }; var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; - var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("cancel"); }; + var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; var onDataErrorCallback = function(error) { console.log("challenge error callback fired"); window.ReactNativeWebView.postMessage("error"); @@ -244,6 +246,7 @@ exports[`Hcaptcha snapshot tests test debug 1`] = ` })();" javaScriptEnabled={true} mixedContentMode="always" + onMessage={[Function]} onShouldStartLoadWithRequest={[Function]} originWhitelist={ [ @@ -294,7 +297,7 @@ exports[`Hcaptcha snapshot tests test debug 1`] = ` console.log("challenge opened"); }; var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; - var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("cancel"); }; + var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage("expired"); }; var onDataErrorCallback = function(error) { console.log("challenge error callback fired"); window.ReactNativeWebView.postMessage("error"); diff --git a/index.js b/index.js index ff4ec5a..0298b47 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,7 @@ class ConfirmHcaptcha extends PureComponent { passiveSiteKey, baseUrl, languageCode, + orientation, onMessage, showLoading, backgroundColor, @@ -110,6 +111,7 @@ ConfirmHcaptcha.propTypes = { baseUrl: PropTypes.string, onMessage: PropTypes.func, languageCode: PropTypes.string, + orientation: PropTypes.string, backgroundColor: PropTypes.string, showLoading: PropTypes.bool, loadingIndicatorColor: PropTypes.string, @@ -130,6 +132,7 @@ ConfirmHcaptcha.defaultProps = { size: 'invisible', passiveSiteKey: false, showLoading: false, + orientation: 'portrait', backgroundColor: 'rgba(0, 0, 0, 0.3)', loadingIndicatorColor: null, theme: 'light',