Created Captcha
This commit is contained in:
parent
e787da4e02
commit
96d2070ae9
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Modal, Stack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import Captcha from './captcha';
|
||||||
|
|
||||||
|
interface ICaptchaModal {
|
||||||
|
onClose: (type?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CaptchaModal: React.FC<ICaptchaModal> = ({ onClose }) => {
|
||||||
|
return (
|
||||||
|
<Modal title={<FormattedMessage id='nostr_signup.captcha_title' defaultMessage='Human Verification' />} onClose={onClose} width='sm'>
|
||||||
|
<Stack justifyContent='center' alignItems='center' space={4}>
|
||||||
|
<Captcha />
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CaptchaModal;
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Button, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import { PuzzleCaptcha } from './puzzle';
|
||||||
|
import useCaptcha from './useCaptcha';
|
||||||
|
|
||||||
|
const Captcha: React.FC = () => {
|
||||||
|
const {
|
||||||
|
captcha,
|
||||||
|
loadCaptcha,
|
||||||
|
handleChangePosition,
|
||||||
|
handleSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
tryAgain,
|
||||||
|
yPosition,
|
||||||
|
xPosition,
|
||||||
|
} = useCaptcha();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2} justifyContent='center' alignItems='center'>
|
||||||
|
<Text align='center'>
|
||||||
|
<FormattedMessage id='nostr_signup.captcha_instruction' defaultMessage='Complete the puzzle by dragging the puzzle piece to the correct position.' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className='relative flex min-h-[358px] min-w-[330px] items-center justify-center'>
|
||||||
|
{captcha ? <PuzzleCaptcha bg={captcha.bg} puzzle={captcha.puzzle} position={{ x: xPosition!, y: yPosition! }} onChange={handleChangePosition} /> : <Spinner size={40} withText={false} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack className='w-[330px]' space={2}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
theme='primary'
|
||||||
|
type='button'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<FormattedMessage id='nostr_signup.captcha_check_button.checking' defaultMessage='Checking…' />
|
||||||
|
) : (tryAgain ?
|
||||||
|
<FormattedMessage id='nostr_signup.captcha_try_again_button' defaultMessage='Try again' /> :
|
||||||
|
<FormattedMessage id='nostr_signup.captcha_check_button' defaultMessage='Check' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={loadCaptcha}>
|
||||||
|
<FormattedMessage id='nostr_signup.captcha_reset_button' defaultMessage='Reset puzzle' />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Captcha;
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface IPuzzleCaptcha {
|
||||||
|
bg: string;
|
||||||
|
puzzle: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
onChange(point: { x: number; y: number }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PuzzleCaptcha: React.FC<IPuzzleCaptcha> = ({ bg, puzzle, position, onChange }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [touchOffsetX, setTouchOffsetX] = useState(0);
|
||||||
|
const [touchOffsetY, setTouchOffsetY] = useState(0);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLImageElement>) => {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLImageElement>) => {
|
||||||
|
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
||||||
|
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const newPosition = {
|
||||||
|
x: e.clientX - rect.left - e.currentTarget.width / 2,
|
||||||
|
y: e.clientY - rect.top - e.currentTarget.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange(newPosition);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (event: React.TouchEvent<HTMLImageElement>) => {
|
||||||
|
const imageElement = event.currentTarget.getBoundingClientRect();
|
||||||
|
const touch = event.touches[0];
|
||||||
|
|
||||||
|
const offsetX = touch.clientX - imageElement.left;
|
||||||
|
const offsetY = touch.clientY - imageElement.top;
|
||||||
|
|
||||||
|
setTouchOffsetX(offsetX);
|
||||||
|
setTouchOffsetY(offsetY);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleTouchMove = (event: React.TouchEvent<HTMLImageElement>) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
|
||||||
|
const dropArea = document.getElementById('drop-area')?.getBoundingClientRect();
|
||||||
|
if (!dropArea) return;
|
||||||
|
|
||||||
|
const newLeft = touch.clientX - dropArea.left - touchOffsetX;
|
||||||
|
const newTop = touch.clientY - dropArea.top - touchOffsetY;
|
||||||
|
|
||||||
|
const imageWidth = 61;
|
||||||
|
const imageHeight = 61;
|
||||||
|
const limitedLeft = Math.min(Math.max(newLeft, 0), dropArea.width - imageWidth);
|
||||||
|
const limitedTop = Math.min(Math.max(newTop, 0), dropArea.height - imageHeight);
|
||||||
|
|
||||||
|
onChange({ x: limitedLeft, y: limitedTop });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLImageElement>) => {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchDrop = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const dropArea = document.getElementById('drop-area')?.getBoundingClientRect();
|
||||||
|
if (!dropArea) return;
|
||||||
|
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
|
||||||
|
const newLeft = touch.clientX - dropArea.left - touchOffsetX;
|
||||||
|
const newTop = touch.clientY - dropArea.top - touchOffsetY;
|
||||||
|
|
||||||
|
const imageWidth = 61;
|
||||||
|
const imageHeight = 61;
|
||||||
|
|
||||||
|
const limitedLeft = Math.min(Math.max(newLeft, 0), dropArea.width - imageWidth);
|
||||||
|
const limitedTop = Math.min(Math.max(newTop, 0), dropArea.height - imageHeight);
|
||||||
|
|
||||||
|
onChange({ x: limitedLeft, y: limitedTop });
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id='drop-area' ref={ref} className='relative' onTouchEnd={handleTouchDrop}>
|
||||||
|
<img
|
||||||
|
className='drop-shadow-black absolute z-[101] w-[61px] drop-shadow-2xl hover:cursor-grab'
|
||||||
|
src={puzzle}
|
||||||
|
alt=''
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
style={{
|
||||||
|
filter: 'drop-shadow(2px 0 0 #fff) drop-shadow(0 2px 0 #fff) drop-shadow(-2px 0 0 #fff) drop-shadow(0 -2px 0 #fff)',
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<img src={bg} alt='' className='rounded-2xl' draggable={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { closeModal } from 'soapbox/actions/modals';
|
||||||
|
import { startOnboarding } from 'soapbox/actions/onboarding';
|
||||||
|
import { useApi, useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||||
|
import { captchaSchema, type CaptchaData } from 'soapbox/schemas/captcha';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
sucessMessage: { id: 'nostr_signup.captcha_message.sucess', defaultMessage: 'Incredible! You\'ve successfully completed the captcha. Let\'s move on to the next step!' },
|
||||||
|
wrongMessage: { id: 'nostr_signup.captcha_message.wrong', defaultMessage: 'Oops! It looks like your captcha response was incorrect. Please try again.' },
|
||||||
|
errorMessage: { id: 'nostr_signup.captcha_message.error', defaultMessage: 'It seems an error has occurred. Please try again. If the problem persists, please contact us.' },
|
||||||
|
misbehavingMessage: { id: 'nostr_signup.captcha_message.misbehaving', defaultMessage: 'It looks like we\'re experiencing issues with the {instance}. Please try again. If the error persists, try again later.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function getRandomNumber(min: number, max: number): number {
|
||||||
|
return Number((Math.random() * (max - min) + min).toFixed());
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCaptcha = () => {
|
||||||
|
const api = useApi();
|
||||||
|
const instance = useInstance();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [captcha, setCaptcha] = useState<CaptchaData>();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
const [tryAgain, setTryAgain] = useState<boolean>(false);
|
||||||
|
const [yPosition, setYPosition] = useState<number>();
|
||||||
|
const [xPosition, setXPosition] = useState<number>();
|
||||||
|
|
||||||
|
const loadCaptcha = async () => {
|
||||||
|
try {
|
||||||
|
const topI = getRandomNumber(0, (356 - 61));
|
||||||
|
const leftI = getRandomNumber(0, (330 - 61));
|
||||||
|
const { data } = await api.get('/api/v1/ditto/captcha');
|
||||||
|
if (data) {
|
||||||
|
const normalizedData = captchaSchema.parse(data);
|
||||||
|
setCaptcha(normalizedData);
|
||||||
|
setYPosition(topI);
|
||||||
|
setXPosition(leftI);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error loading captcha:');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCaptcha();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangePosition = (point: { x: number; y: number }) => {
|
||||||
|
setXPosition(point.x);
|
||||||
|
setYPosition(point.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
if (captcha) {
|
||||||
|
const result = {
|
||||||
|
x: xPosition,
|
||||||
|
y: yPosition,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/ditto/captcha/${captcha.id}/verify`, result).then(() => {
|
||||||
|
setTryAgain(true);
|
||||||
|
|
||||||
|
dispatch(closeModal('CAPTCHA'));
|
||||||
|
toast.success(messages.sucessMessage);
|
||||||
|
dispatch(startOnboarding());
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setTryAgain(true);
|
||||||
|
const error = e as AxiosError;
|
||||||
|
const status = error.request?.status;
|
||||||
|
|
||||||
|
let message;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
message = intl.formatMessage(messages.wrongMessage);
|
||||||
|
break;
|
||||||
|
case 422:
|
||||||
|
message = intl.formatMessage(messages.misbehavingMessage, { instance: instance.title });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = intl.formatMessage(messages.errorMessage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
captcha,
|
||||||
|
loadCaptcha,
|
||||||
|
handleChangePosition,
|
||||||
|
handleSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
tryAgain,
|
||||||
|
yPosition,
|
||||||
|
xPosition,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCaptcha;
|
Loading…
Reference in New Issue