-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(turnstile): Use Cloudflare Turnstile instead of Google reCAPTCHA
resolves #3313
- Loading branch information
49659410+tx0c
committed
Sep 23, 2023
1 parent
5e9033a
commit ba917ee
Showing
11 changed files
with
980 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import type { ComponentPropsWithoutRef, ElementType, ForwardedRef } from 'react' | ||
import { forwardRef } from 'react' | ||
|
||
type Props<Tag extends ElementType> = { | ||
as?: Tag | ||
} & ComponentPropsWithoutRef<Tag> | ||
|
||
type ComponentProps<Tag extends ElementType = 'div'> = | ||
Tag extends keyof JSX.IntrinsicElements | ||
? Props<Tag> // @typescript-eslint/no-explicit-any | ||
: // eslint-disable-next-line | ||
Props<any> | ||
|
||
const Component = <Tag extends ElementType = 'div'>( | ||
{ as: Element = 'div', ...props }: ComponentProps<Tag>, | ||
ref: ForwardedRef<Element> | ||
) => { | ||
return <Element {...props} ref={ref} /> | ||
} | ||
|
||
export default forwardRef(Component) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
export * from './lib' | ||
export type { | ||
TurnstileInstance, | ||
TurnstileLangCode, | ||
TurnstileProps, | ||
TurnstileServerValidationErrorCode, | ||
TurnstileServerValidationResponse, | ||
TurnstileTheme, | ||
} from './types' | ||
export { | ||
DEFAULT_CONTAINER_ID, | ||
DEFAULT_ONLOAD_NAME, | ||
DEFAULT_SCRIPT_ID, | ||
SCRIPT_URL, | ||
} from './utils' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,304 @@ | ||
import { | ||
forwardRef, | ||
useEffect, | ||
useImperativeHandle, | ||
useMemo, | ||
useRef, | ||
useState, | ||
} from 'react' | ||
|
||
import Container from './container' | ||
import { RenderOptions, TurnstileInstance, TurnstileProps } from './types' | ||
import useObserveScript from './use-observe-script' | ||
import { | ||
checkElementExistence, | ||
CONTAINER_STYLE_SET, | ||
DEFAULT_CONTAINER_ID, | ||
DEFAULT_ONLOAD_NAME, | ||
DEFAULT_SCRIPT_ID, | ||
getTurnstileSizeOpts, | ||
injectTurnstileScript, | ||
} from './utils' | ||
|
||
export const Turnstile = forwardRef< | ||
TurnstileInstance | undefined, | ||
TurnstileProps | ||
>((props, ref) => { | ||
const { | ||
scriptOptions, | ||
options = {}, | ||
siteKey, | ||
onSuccess, | ||
onExpire, | ||
onError, | ||
onBeforeInteractive, | ||
onAfterInteractive, | ||
onUnsupported, | ||
id, | ||
style, | ||
as = 'div', | ||
injectScript = true, | ||
...divProps | ||
} = props | ||
const widgetSize = options.size ?? 'normal' | ||
|
||
const [containerStyle, setContainerStyle] = useState( | ||
options.execution === 'execute' | ||
? CONTAINER_STYLE_SET.invisible | ||
: options.appearance === 'interaction-only' | ||
? CONTAINER_STYLE_SET.interactionOnly | ||
: CONTAINER_STYLE_SET[widgetSize] | ||
) | ||
const containerRef = useRef<HTMLElement | null>(null) | ||
const firstRendered = useRef(false) | ||
const [widgetId, setWidgetId] = useState<string | undefined | null>() | ||
const [turnstileLoaded, setTurnstileLoaded] = useState(false) | ||
const containerId = id ?? DEFAULT_CONTAINER_ID | ||
const scriptId = injectScript | ||
? scriptOptions?.id || `${DEFAULT_SCRIPT_ID}__${containerId}` | ||
: scriptOptions?.id || DEFAULT_SCRIPT_ID | ||
const scriptLoaded = useObserveScript(scriptId) | ||
|
||
const onLoadCallbackName = scriptOptions?.onLoadCallbackName | ||
? `${scriptOptions.onLoadCallbackName}__${containerId}` | ||
: `${DEFAULT_ONLOAD_NAME}__${containerId}` | ||
|
||
const renderConfig = useMemo( | ||
(): RenderOptions => ({ | ||
sitekey: siteKey, | ||
action: options.action, | ||
cData: options.cData, | ||
callback: onSuccess, | ||
'error-callback': onError, | ||
'expired-callback': onExpire, | ||
'before-interactive-callback': onBeforeInteractive, | ||
'after-interactive-callback': onAfterInteractive, | ||
'unsupported-callback': onUnsupported, | ||
theme: options.theme ?? 'auto', | ||
language: options.language ?? 'auto', | ||
tabindex: options.tabIndex, | ||
'response-field': options.responseField, | ||
'response-field-name': options.responseFieldName, | ||
size: getTurnstileSizeOpts(widgetSize), | ||
retry: options.retry ?? 'auto', | ||
'retry-interval': options.retryInterval ?? 8000, | ||
'refresh-expired': options.refreshExpired ?? 'auto', | ||
execution: options.execution ?? 'render', | ||
appearance: options.appearance ?? 'always', | ||
}), | ||
[ | ||
siteKey, | ||
options, | ||
onSuccess, | ||
onError, | ||
onExpire, | ||
widgetSize, | ||
onBeforeInteractive, | ||
onAfterInteractive, | ||
onUnsupported, | ||
] | ||
) | ||
|
||
const renderConfigStringified = useMemo( | ||
() => JSON.stringify(renderConfig), | ||
[renderConfig] | ||
) | ||
|
||
useImperativeHandle( | ||
ref, | ||
() => { | ||
if (typeof window === 'undefined' || !scriptLoaded) { | ||
return | ||
} | ||
|
||
const { turnstile } = window | ||
return { | ||
getResponse() { | ||
if (!turnstile?.getResponse || !widgetId) { | ||
console.warn('Turnstile has not been loaded') | ||
return | ||
} | ||
|
||
return turnstile.getResponse(widgetId) | ||
}, | ||
|
||
reset() { | ||
if (!turnstile?.reset || !widgetId) { | ||
console.warn('Turnstile has not been loaded') | ||
return | ||
} | ||
|
||
if (options.execution === 'execute') { | ||
setContainerStyle(CONTAINER_STYLE_SET.invisible) | ||
} | ||
|
||
try { | ||
turnstile.reset(widgetId) | ||
} catch (error) { | ||
console.warn(`Failed to reset Turnstile widget ${widgetId}`, error) | ||
} | ||
}, | ||
|
||
remove() { | ||
if (!turnstile?.remove || !widgetId) { | ||
console.warn('Turnstile has not been loaded') | ||
return | ||
} | ||
|
||
setWidgetId('') | ||
setContainerStyle(CONTAINER_STYLE_SET.invisible) | ||
turnstile.remove(widgetId) | ||
}, | ||
|
||
render() { | ||
if (!turnstile?.render || !containerRef.current || widgetId) { | ||
console.warn( | ||
'Turnstile has not been loaded or widget already rendered' | ||
) | ||
return | ||
} | ||
|
||
const id = turnstile.render(containerRef.current, renderConfig) | ||
setWidgetId(id) | ||
|
||
if (options.execution !== 'execute') { | ||
setContainerStyle(CONTAINER_STYLE_SET[widgetSize]) | ||
} | ||
|
||
return id | ||
}, | ||
|
||
execute() { | ||
if (options.execution !== 'execute') { | ||
return | ||
} | ||
|
||
if (!turnstile?.execute || !containerRef.current || !widgetId) { | ||
console.warn( | ||
'Turnstile has not been loaded or widget has not been rendered' | ||
) | ||
return | ||
} | ||
|
||
turnstile.execute(containerRef.current, renderConfig) | ||
setContainerStyle(CONTAINER_STYLE_SET[widgetSize]) | ||
}, | ||
} | ||
}, | ||
[ | ||
scriptLoaded, | ||
widgetId, | ||
options.execution, | ||
widgetSize, | ||
renderConfig, | ||
containerRef, | ||
] | ||
) | ||
|
||
useEffect(() => { | ||
// @ts-expect-error implicit any | ||
window[onLoadCallbackName] = () => setTurnstileLoaded(true) | ||
|
||
return () => { | ||
// @ts-expect-error implicit any | ||
delete window[onLoadCallbackName] | ||
} | ||
}, [onLoadCallbackName]) | ||
|
||
useEffect(() => { | ||
if (injectScript && !turnstileLoaded) { | ||
injectTurnstileScript({ | ||
onLoadCallbackName, | ||
scriptOptions: { | ||
...scriptOptions, | ||
id: scriptId, | ||
}, | ||
}) | ||
} | ||
}, [ | ||
injectScript, | ||
turnstileLoaded, | ||
onLoadCallbackName, | ||
scriptOptions, | ||
scriptId, | ||
]) | ||
|
||
/* Set the turnstile as loaded, in case the onload callback never runs. (e.g., when manually injecting the script without specifying the `onload` param) */ | ||
useEffect(() => { | ||
if (scriptLoaded && !turnstileLoaded && window.turnstile) { | ||
setTurnstileLoaded(true) | ||
} | ||
}, [turnstileLoaded, scriptLoaded]) | ||
|
||
useEffect(() => { | ||
if (!siteKey) { | ||
console.warn('sitekey was not provided') | ||
return | ||
} | ||
|
||
if ( | ||
!scriptLoaded || | ||
!containerRef.current || | ||
!turnstileLoaded || | ||
firstRendered.current | ||
) { | ||
return | ||
} | ||
|
||
const id = window.turnstile!.render(containerRef.current, renderConfig) | ||
setWidgetId(id) | ||
firstRendered.current = true | ||
}, [scriptLoaded, siteKey, renderConfig, firstRendered, turnstileLoaded]) | ||
|
||
// re-render widget when renderConfig changes | ||
useEffect(() => { | ||
if (!window.turnstile) return | ||
|
||
if (containerRef.current && widgetId) { | ||
if (checkElementExistence(widgetId)) { | ||
window.turnstile.remove(widgetId) | ||
} | ||
const newWidgetId = window.turnstile.render( | ||
containerRef.current, | ||
renderConfig | ||
) | ||
setWidgetId(newWidgetId) | ||
firstRendered.current = true | ||
} | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, [renderConfigStringified, siteKey]) | ||
|
||
useEffect(() => { | ||
if (!window.turnstile) return | ||
if (!widgetId) return | ||
if (!checkElementExistence(widgetId)) return | ||
|
||
return () => { | ||
window.turnstile!.remove(widgetId) | ||
} | ||
}, [widgetId]) | ||
|
||
useEffect(() => { | ||
setContainerStyle( | ||
options.execution === 'execute' | ||
? CONTAINER_STYLE_SET.invisible | ||
: renderConfig.appearance === 'interaction-only' | ||
? CONTAINER_STYLE_SET.interactionOnly | ||
: CONTAINER_STYLE_SET[widgetSize] | ||
) | ||
}, [options.execution, widgetSize, renderConfig.appearance]) | ||
|
||
return ( | ||
<Container | ||
ref={containerRef} | ||
as={as} | ||
id={containerId} | ||
style={{ ...containerStyle, ...style }} | ||
{...divProps} | ||
/> | ||
) | ||
}) | ||
|
||
Turnstile.displayName = 'Turnstile' | ||
|
||
export default Turnstile |
Oops, something went wrong.