Merge branch 'captcha-modal' into 'main'

Captcha modal

Closes #1748

See merge request soapbox-pub/soapbox!3144
This commit is contained in:
Alex Gleason 2024-10-10 22:17:16 +00:00
commit d989e1ebbd
11 changed files with 290 additions and 4 deletions

View File

@ -0,0 +1,112 @@
import { AxiosError } from 'axios';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { closeModal } from 'soapbox/actions/modals';
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);
});
} 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;

View File

@ -111,6 +111,8 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
}));
} else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') {
dispatch(closeModal('CONFIRM'));
} else if (type === 'CAPTCHA') {
return;
} else {
onClose();
}

View File

@ -46,6 +46,7 @@ import {
ZapSplitModal,
ZapInvoiceModal,
ZapsModal,
CaptchaModal,
} from 'soapbox/features/ui/util/async-components';
import ModalLoading from './modal-loading';
@ -56,6 +57,7 @@ const MODAL_COMPONENTS: Record<string, React.ExoticComponent<any>> = {
'ACTIONS': ActionsModal,
'BIRTHDAYS': BirthdaysModal,
'BOOST': BoostModal,
'CAPTCHA': CaptchaModal,
'COMPARE_HISTORY': CompareHistoryModal,
'COMPONENT': ComponentModal,
'COMPOSE': ComposeModal,

View File

@ -0,0 +1,64 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import useCaptcha from 'soapbox/api/hooks/captcha/useCaptcha';
import { Modal, Button, Spinner, Stack, Text } from 'soapbox/components/ui';
import { PuzzleCaptcha } from './components/puzzle';
interface ICaptchaModal {
onClose: (type?: string) => void;
}
const CaptchaModal: React.FC<ICaptchaModal> = ({ onClose }) => {
const {
captcha,
loadCaptcha,
handleChangePosition,
handleSubmit,
isSubmitting,
tryAgain,
yPosition,
xPosition,
} = useCaptcha();
return (
<Modal
title={<FormattedMessage id='nostr_signup.captcha_title' defaultMessage='Human Verification' />} width='sm'
>
<Stack justifyContent='center' alignItems='center' space={4}>
<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>
</Stack>
</Modal>
);
};
export default CaptchaModal;

View File

@ -0,0 +1,64 @@
import React, { useRef } 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 calculateNewPosition = (
clientX: number,
clientY: number,
elementWidth: number,
elementHeight: number,
dropArea: DOMRect,
) => {
const newX = Math.min(Math.max(clientX - dropArea.left - elementWidth / 2, 0), dropArea.width - elementWidth);
const newY = Math.min(Math.max(clientY - dropArea.top - elementHeight / 2, 0), dropArea.height - elementHeight);
return { x: newX, y: newY };
};
const handlePointerMove = (e: React.PointerEvent<HTMLImageElement>) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
const dropArea = ref.current?.getBoundingClientRect();
if (!dropArea) return;
const newPosition = calculateNewPosition(e.clientX, e.clientY, e.currentTarget.width, e.currentTarget.height, dropArea);
onChange(newPosition);
};
const handleTouchMove = (event: React.TouchEvent<HTMLImageElement>) => {
const touch = event.touches[0];
const dropArea = ref.current?.getBoundingClientRect();
if (!dropArea) return;
const newPosition = calculateNewPosition(touch.clientX, touch.clientY, 61, 61, dropArea);
onChange(newPosition);
};
return (
<div id='drop-area' ref={ref} className='relative'>
<img
className='drop-shadow-black absolute z-[101] w-[61px] drop-shadow-2xl hover:cursor-grab'
src={puzzle}
alt=''
onPointerDown={(e) => e.currentTarget.setPointerCapture(e.pointerId)}
onPointerMove={handlePointerMove}
onPointerUp={(e) => e.currentTarget.releasePointerCapture(e.pointerId)}
onTouchMove={handleTouchMove}
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>
);
};

View File

@ -4,13 +4,14 @@ import { FormattedMessage } from 'react-intl';
import { fetchAccount } from 'soapbox/actions/accounts';
import { logInNostr } from 'soapbox/actions/nostr';
import { startOnboarding } from 'soapbox/actions/onboarding';
import { closeSidebar } from 'soapbox/actions/sidebar';
import CopyableInput from 'soapbox/components/copyable-input';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, FormGroup, Text, Tooltip, HStack } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { useIsMobile } from 'soapbox/hooks/useIsMobile';
import { download } from 'soapbox/utils/download';
import { slugify } from 'soapbox/utils/input';
@ -21,6 +22,7 @@ interface IKeygenStep {
const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
const instance = useInstance();
const dispatch = useAppDispatch();
const isMobile = useIsMobile();
const { relay } = useNostr();
const secretKey = useMemo(() => generateSecretKey(), []);
@ -62,9 +64,13 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
await Promise.all(events.map((event) => relay?.event(event)));
await dispatch(logInNostr(pubkey));
dispatch(startOnboarding());
onClose();
if (isMobile) {
dispatch(closeSidebar());
}
};
return (
@ -82,7 +88,6 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
</Text>
</Stack>
<HStack space={6} justifyContent='center' >
<Button theme='secondary' size='lg' icon={require('@tabler/icons/outline/download.svg')} onClick={handleDownload}>
<FormattedMessage id='nostr_signup.keygen.download_key_button' defaultMessage='Download key' />

View File

@ -180,3 +180,4 @@ export const ZapPayRequestModal = lazy(() => import('soapbox/features/ui/compone
export const ZapInvoiceModal = lazy(() => import('soapbox/features/ui/components/modals/zap-invoice'));
export const ZapsModal = lazy(() => import('soapbox/features/ui/components/modals/zaps-modal'));
export const ZapSplitModal = lazy(() => import('soapbox/features/ui/components/modals/zap-split/zap-split-modal'));
export const CaptchaModal = lazy(() => import('soapbox/features/ui/components/modals/captcha-modal/captcha-modal'));

View File

@ -1,4 +1,4 @@
import React, { Suspense } from 'react';
import React, { Suspense, useEffect } from 'react';
import { Toaster } from 'react-hot-toast';
import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
@ -35,11 +35,20 @@ const SoapboxMount = () => {
const soapboxConfig = useSoapboxConfig();
const showCaptcha = account && account?.source?.ditto.captcha_solved === false;
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
const showOnboarding = account && needsOnboarding;
useEffect(() => {
if (showCaptcha) {
dispatch(openModal('CAPTCHA'));
}
}, [showCaptcha]);
if (showOnboarding) {
dispatch(openModal('ONBOARDING_FLOW'));
}
const { redirectRootNoLogin, gdpr } = soapboxConfig;
// @ts-ignore: I don't actually know what these should be, lol

View File

@ -1176,6 +1176,16 @@
"nostr_relays.title": "Relays",
"nostr_relays.write_only": "Write-only",
"nostr_signin.siwe.welcome": "Welcome to {site_title}",
"nostr_signup.captcha_check_button": "Check",
"nostr_signup.captcha_check_button.checking": "Checking…",
"nostr_signup.captcha_instruction": "Complete the puzzle by dragging the puzzle piece to the correct position.",
"nostr_signup.captcha_message.error": "It seems an error has occurred. Please try again. If the problem persists, please contact us.",
"nostr_signup.captcha_message.misbehaving": "It looks like we're experiencing issues with the {instance}. Please try again. If the error persists, try again later.",
"nostr_signup.captcha_message.sucess": "Incredible! You've successfully completed the captcha. Let's move on to the next step!",
"nostr_signup.captcha_message.wrong": "Oops! It looks like your captcha response was incorrect. Please try again.",
"nostr_signup.captcha_reset_button": "Reset puzzle",
"nostr_signup.captcha_title": "Human Verification",
"nostr_signup.captcha_try_again_button": "Try again",
"nostr_signup.has_key": "I already have a key",
"nostr_signup.key-add.key_button": "Add Key",
"nostr_signup.key-add.title": "Import Key",

View File

@ -110,6 +110,9 @@ const baseAccountSchema = z.object({
nostr: z.object({
nip05: z.string().optional().catch(undefined),
}).optional().catch(undefined),
ditto: coerceObject({
captcha_solved: z.boolean().catch(true),
}),
}).optional().catch(undefined),
statuses_count: z.number().catch(0),
suspended: z.boolean().catch(false),

14
src/schemas/captcha.ts Normal file
View File

@ -0,0 +1,14 @@
import { z } from 'zod';
const captchaSchema = z.object({
bg: z.string().catch(''),
created_at: z.string().catch(''),
expires_at: z.string().catch(''),
id: z.string().catch(''),
puzzle: z.string().catch(''),
type: z.string().catch(''),
});
type CaptchaData = z.infer<typeof captchaSchema>;
export { captchaSchema, type CaptchaData };