Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
448 changes: 384 additions & 64 deletions Example.App.js

Large diffs are not rendered by default.

34 changes: 32 additions & 2 deletions Hcaptcha.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,39 @@ export type HCaptchaVerifyParams = {
rqdata?: string;
phonePrefix?: string;
phoneNumber?: string;
mfaEmail?: string;
};

export type HCaptchaHandle = {
/**
* Executes hCaptcha with optional verification parameters for this attempt.
* Calls made before readiness are queued until initialization completes.
*/
execute: (verifyParams?: HCaptchaVerifyParams) => void;
/**
* Resets the current hCaptcha widget without executing it.
*/
reset: () => void;
/**
* Closes the current challenge without unmounting the preloaded widget.
*/
close: () => void;
};

export type HcaptchaProps = {
/**
* The callback function that runs after receiving a response, error, or when user cancels.
*/
onMessage: (event: CustomWebViewMessageEvent) => void;
/**
* Runs when hCaptcha has loaded and rendered the widget.
*/
onReady?: () => void;
/**
* Whether to execute automatically after hCaptcha is ready.
* Defaults to true. Set to false to preload the inline component.
*/
autoExecute?: boolean;
/**
* The size of the checkbox.
*/
Expand Down Expand Up @@ -115,10 +141,14 @@ export type HcaptchaProps = {
userJourney?: boolean;
}

interface CustomWebViewMessageEvent extends WebViewMessageEvent {
export interface CustomWebViewMessageEvent extends WebViewMessageEvent {
success: boolean;
reset: () => void;
markUsed?: () => void;
}

export default class Hcaptcha extends React.Component<HcaptchaProps> {}
export default class Hcaptcha extends React.Component<HcaptchaProps> {
execute: HCaptchaHandle['execute'];
reset: HCaptchaHandle['reset'];
close: HCaptchaHandle['close'];
}
141 changes: 123 additions & 18 deletions Hcaptcha.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import hCaptchaLoaderInlineScript from '@hcaptcha/loader/inline';
import WebView from 'react-native-webview';
import { ActivityIndicator, Linking, Platform, StyleSheet, TouchableWithoutFeedback, View } from 'react-native';
Expand Down Expand Up @@ -116,6 +124,7 @@ const buildVerifyData = ({
const finalRqdata = normalizedVerifyParams.rqdata ?? rqdata ?? undefined;
const finalPhonePrefix = normalizedVerifyParams.phonePrefix ?? phonePrefix ?? undefined;
const finalPhoneNumber = normalizedVerifyParams.phoneNumber ?? phoneNumber ?? undefined;
const finalMfaEmail = normalizedVerifyParams.mfaEmail ?? undefined;

if (finalRqdata) {
data.rqdata = finalRqdata;
Expand All @@ -126,6 +135,9 @@ const buildVerifyData = ({
if (finalPhoneNumber) {
data.mfa_phone = finalPhoneNumber;
}
if (finalMfaEmail) {
data.mfa_email = finalMfaEmail;
}
if (Array.isArray(userJourney) && userJourney.length > 0) {
data.userjourney = userJourney;
}
Expand Down Expand Up @@ -175,6 +187,8 @@ function buildHcaptchaLoaderConfig({
/**
*
* @param {*} onMessage: callback after receiving response, error, or when user cancels
* @param {function} onReady: callback when hCaptcha is ready to execute
* @param {boolean} autoExecute: execute automatically after hCaptcha is ready
* @param {*} siteKey: your hCaptcha sitekey
* @param {string} size: The size of the widget, can be 'invisible', 'compact' or 'normal'. 'checkbox' is kept as a legacy alias for 'normal'. Default: 'invisible'
* @param {*} style: custom style
Expand All @@ -200,8 +214,10 @@ function buildHcaptchaLoaderConfig({
* @param {boolean} userJourney: Enable automatic user journey injection
* @param {object} verifyParams: Verification payload overrides
*/
const Hcaptcha = ({
const Hcaptcha = forwardRef(({
onMessage,
onReady,
autoExecute = true,
size,
siteKey,
style,
Expand All @@ -227,11 +243,18 @@ const Hcaptcha = ({
userJourney,
verifyParams,
_journeyManagedExternally,
}) => {
}, ref) => {
const tokenTimeout = 120000;
const loadingTimeout = 15000;
const [isLoading, setIsLoading] = useState(true);
const isLoadingRef = useRef(true);
const isReadyRef = useRef(false);
const hasExecutedRef = useRef(false);
const lastExecutionVerifyParamsRef = useRef(undefined);
const pendingExecutionRef = useRef({
pending: false,
verifyParams: undefined,
});
const journeyEnabled = Boolean(userJourney);
const hasJourneyConsumerRef = useRef(false);
const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]);
Expand Down Expand Up @@ -309,6 +332,9 @@ const Hcaptcha = ({
var reset = function() {
hcaptcha.reset(hcaptchaWidgetId);
};
var closeChallenge = function() {
hcaptcha.close(hcaptchaWidgetId);
};
var onloadCallback = function() {
try {
console.log("challenge onload starting");
Expand All @@ -332,8 +358,8 @@ const Hcaptcha = ({
window.ReactNativeWebView.postMessage("open");
console.log("challenge opened");
};
var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage(error); };
var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage(error); };
var onDataExpiredCallback = function() { window.ReactNativeWebView.postMessage("expired"); };
var onChalExpiredCallback = function() { window.ReactNativeWebView.postMessage("challenge-expired"); };
var onDataErrorCallback = function(error) {
console.warn("challenge error callback fired");
window.ReactNativeWebView.postMessage(error);
Expand Down Expand Up @@ -394,19 +420,77 @@ const Hcaptcha = ({
}, [onMessage]);

const webViewRef = useRef(null);
const injectVerifyData = (resetFirst = false) => {
const injectVerifyData = useCallback((resetFirst = false, executionVerifyParams) => {
if (!webViewRef.current) {
return;
return false;
}

const finalVerifyParams = executionVerifyParams === undefined
? verifyParams
: {
...(verifyParams || {}),
...executionVerifyParams,
};

webViewRef.current.injectJavaScript(buildVerifyInjectionScript(buildVerifyData({
phoneNumber,
phonePrefix,
rqdata,
userJourney: journeyEnabled ? peekJourneyEvents() : undefined,
verifyParams,
verifyParams: finalVerifyParams,
}), resetFirst));
};

return true;
}, [journeyEnabled, phoneNumber, phonePrefix, rqdata, verifyParams]);

const executeNow = useCallback((executionVerifyParams) => {
if (injectVerifyData(hasExecutedRef.current, executionVerifyParams)) {
hasExecutedRef.current = true;
lastExecutionVerifyParamsRef.current = executionVerifyParams;
}
}, [injectVerifyData]);

const execute = useCallback((executionVerifyParams) => {
if (!isReadyRef.current) {
pendingExecutionRef.current = {
pending: true,
verifyParams: executionVerifyParams,
};
return;
}

executeNow(executionVerifyParams);
}, [executeNow]);

const resetWidget = useCallback(() => {
pendingExecutionRef.current = {
pending: false,
verifyParams: undefined,
};
hasExecutedRef.current = false;
lastExecutionVerifyParamsRef.current = undefined;

if (isReadyRef.current && webViewRef.current) {
webViewRef.current.injectJavaScript('reset(); true;');
}
}, []);

const closeWidget = useCallback(() => {
pendingExecutionRef.current = {
pending: false,
verifyParams: undefined,
};

if (isReadyRef.current && webViewRef.current) {
webViewRef.current.injectJavaScript('closeChallenge(); true;');
}
}, []);

useImperativeHandle(ref, () => ({
execute,
reset: resetWidget,
close: closeWidget,
}), [closeWidget, execute, resetWidget]);

// This shows ActivityIndicator till webview loads hCaptcha images
const renderLoading = () => (
Expand All @@ -417,17 +501,21 @@ const Hcaptcha = ({
</TouchableWithoutFeedback>
);

const reset = () => {
injectVerifyData(true);
};
const retryVerification = useCallback(() => {
if (injectVerifyData(true, lastExecutionVerifyParamsRef.current)) {
hasExecutedRef.current = true;
}
}, [injectVerifyData]);

const retryApiLoad = () => {
const retryApiLoad = useCallback(() => {
if (!webViewRef.current) {
return;
}

isReadyRef.current = false;
hasExecutedRef.current = false;
webViewRef.current.injectJavaScript('loadApiScript(); true;');
};
}, []);

return (
<View style={styles.container}>
Expand Down Expand Up @@ -459,19 +547,34 @@ const Hcaptcha = ({
setIsLoading(false);

if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) {
injectVerifyData();
const pendingExecution = pendingExecutionRef.current;
pendingExecutionRef.current = {
pending: false,
verifyParams: undefined,
};
isReadyRef.current = true;

if (pendingExecution.pending) {
executeNow(pendingExecution.verifyParams);
} else if (autoExecute) {
executeNow();
}

if (onReady) {
onReady();
}
return;
}

if (e.nativeEvent.data === 'script-error') {
e.reset = retryApiLoad;
} else {
e.reset = reset;
e.reset = retryVerification;
}
e.success = true;
if (e.nativeEvent.data === 'open') {
} else if (e.nativeEvent.data.length > 35) {
const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout);
const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset: retryVerification }), tokenTimeout);
e.markUsed = () => clearTimeout(expiredTokenTimerId);
if (journeyEnabled) {
clearJourneyEvents();
Expand All @@ -493,7 +596,7 @@ const Hcaptcha = ({
{showLoading && isLoading && renderLoading()}
</View>
);
};
});

const styles = StyleSheet.create({
container: {
Expand All @@ -509,5 +612,7 @@ const styles = StyleSheet.create({
},
});

Hcaptcha.displayName = 'Hcaptcha';

export default Hcaptcha;
export { buildDebugInfo, buildVerifyData, HCAPTCHA_READY_EVENT };
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,12 @@ Use `verifyParams` for request data passed to `hcaptcha.setData(...)` immediatel
rqdata: enterpriseRqdata,
phonePrefix: '44',
phoneNumber: '+44123456789',
mfaEmail: 'user@example.com',
}}
/>
```

Legacy top-level `rqdata`, `phonePrefix`, and `phoneNumber` props still work, but `verifyParams` takes precedence and should be preferred for new code.
Legacy top-level `rqdata`, `phonePrefix`, and `phoneNumber` props still work, but `verifyParams` takes precedence and should be preferred for new code. `mfaEmail` is available through `verifyParams`.

### User Journeys (Enterprise)

Expand Down Expand Up @@ -193,6 +194,43 @@ import { Hcaptcha } from '@hcaptcha/react-native-hcaptcha';
/>
```

To preload hCaptcha, keep the inline component mounted with `autoExecute={false}` and execute it later through its ref. Verification parameters passed to `execute()` apply only to that attempt and take precedence over the component props.

```js
import React, { useRef } from 'react';
import { Button, View } from 'react-native';
import { Hcaptcha } from '@hcaptcha/react-native-hcaptcha';

export default function Example() {
const captchaRef = useRef(null);

return (
<View>
<Button
title="Continue"
onPress={() => captchaRef.current?.execute({
rqdata: 'current-enterprise-rqdata',
})}
/>
<Button
title="Cancel challenge"
onPress={() => captchaRef.current?.close()}
/>
<Hcaptcha
ref={captchaRef}
autoExecute={false}
siteKey="your-site-key"
url="https://hcaptcha.com"
onReady={() => console.log('hCaptcha is ready')}
onMessage={onMessage}
/>
</View>
);
}
```

The inline component must remain mounted between initialization and execution. The surrounding view is responsible for positioning it when a visual challenge is shown. Calling `close()` dismisses an active challenge without unmounting the preloaded widget.

### 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.
Expand Down Expand Up @@ -347,14 +385,16 @@ For new code, prefer:
| siteKey _(required)_ | string | The hCaptcha siteKey |
| size | string | The size of the widget, can be 'invisible', 'compact' or 'normal'. `checkbox` is also accepted as a legacy alias for `normal`. Default: 'invisible' |
| onMessage | Function (see [here](https://github.com/react-native-webview/react-native-webview/blob/master/src/WebViewTypes.ts#L299)) | Required. Runs after receiving a response, error, or when user cancels. |
| onReady _(inline component only)_ | Function | Runs when hCaptcha has loaded and rendered the widget. |
| autoExecute _(inline component only)_ | boolean | Whether to execute automatically after hCaptcha is ready. Defaults to `true`; set to `false` to preload the component. |
| languageCode | string | Default language for hCaptcha; overrides phone defaults. A complete list of supported languages and their codes can be found [here](https://docs.hcaptcha.com/languages/) |
| showLoading | boolean | Whether to show a loading indicator while the hCaptcha web content loads |
| closableLoading | boolean | Allow user to cancel hcaptcha during loading by touch loader overlay |
| loadingIndicatorColor | string | Color of the ActivityIndicator |
| backgroundColor | string | The background color code that will be applied to the main HTML element |
| theme | string\|object | The theme can be 'light', 'dark', 'contrast' or a custom theme object (see Enterprise docs) |
| rqdata | string | **Deprecated**: Use `rqdata` in `HCaptchaVerifyParams` instead. Will be removed in future releases. See Enterprise docs. |
| verifyParams | object | Verification payload overrides passed to `hcaptcha.setData(...)` immediately before verification. Supports `rqdata`, `phonePrefix`, and `phoneNumber`. |
| verifyParams | object | Verification payload overrides passed to `hcaptcha.setData(...)` immediately before verification. Supports `rqdata`, `phonePrefix`, `phoneNumber`, and `mfaEmail`. |
| userJourney | boolean | When `true`, attaches the current shared journey buffer to the verification payload as `userjourney`. It also enables automatic touch capture by default while a `userJourney` captcha instance is mounted. Use `initJourneyTracking({ touchCapture: false })` to keep User Journeys enabled without automatic touch capture. |
| sentry | boolean | Enables hCaptcha error reporting, including API loading failures. Set to `false` to disable (see Enterprise docs). |
| jsSrc | string | The url of api.js. Default: https://js.hcaptcha.com/1/api.js (Override only if using first-party hosting feature.) |
Expand Down
Loading
Loading