Skip to content

Commit

Permalink
add back / fix recaptcha
Browse files Browse the repository at this point in the history
  • Loading branch information
sspenst committed May 25, 2024
1 parent b1e59d6 commit c715f0a
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 48 deletions.
66 changes: 52 additions & 14 deletions components/forms/signupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,68 @@ import { AppContext } from '../../contexts/appContext';
import LoadingSpinner from '../page/loadingSpinner';
import FormTemplate from './formTemplate';

export default function SignupForm() {
interface SignupFormProps {
recaptchaPublicKey: string | null;
}

export default function SignupForm({ recaptchaPublicKey }: SignupFormProps) {
const { cache } = useSWRConfig();
const [email, setEmail] = useState<string>('');
const { mutateUser, setShouldAttemptAuth } = useContext(AppContext);
const [password, setPassword] = useState<string>('');
const recaptchaRef = useRef<ReCAPTCHA>(null);
const router = useRouter();
const [showRecaptcha, setShowRecaptcha] = useState<boolean>(false);
const [username, setUsername] = useState<string>('');

function onSubmit(event: React.FormEvent) {
event.preventDefault();

function onSubmit(recaptchaToken: string | null) {
if (password.length < 8 || password.length > 50) {
toast.dismiss();
toast.error('Password must be between 8 and 50 characters');

return;
}

if (recaptchaPublicKey) {
if (!showRecaptcha) {
setShowRecaptcha(true);

return;
}

if (!recaptchaToken) {
toast.error('Please complete the recaptcha');

return;
}

if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
}

toast.dismiss();
toast.loading('Registering...');

const tutorialCompletedAt = window.localStorage.getItem('tutorialCompletedAt') || '0';
const utm_source = window.localStorage.getItem('utm_source') || '';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body: any = {
email: email,
name: username,
password: password,
tutorialCompletedAt: parseInt(tutorialCompletedAt),
utm_source: utm_source
};

if (recaptchaToken) {
body.recaptchaToken = recaptchaToken;
}

fetch('/api/signup', {
method: 'POST',
body: JSON.stringify({
email: email,
name: username,
password: password,
tutorialCompletedAt: parseInt(tutorialCompletedAt),
utm_source: utm_source
}),
body: JSON.stringify(body),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
Expand Down Expand Up @@ -131,7 +159,7 @@ export default function SignupForm() {

return (
<FormTemplate title='Create your Thinky.gg account'>
<form className='flex flex-col gap-6' onSubmit={onSubmit}>
<div className='flex flex-col gap-6'>
<StepWizard className='w-full' instance={setWizard}>
<div className='flex flex-col gap-6'>
<div>
Expand Down Expand Up @@ -191,7 +219,17 @@ export default function SignupForm() {
I agree to the <a className='underline' href='https://docs.google.com/document/d/e/2PACX-1vR4E-RcuIpXSrRtR3T3y9begevVF_yq7idcWWx1A-I9w_VRcHhPTkW1A7DeUx2pGOcyuKifEad3Qokn/pub' rel='noreferrer' target='_blank'>terms of service</a> and reviewed the <a className='underline' href='https://docs.google.com/document/d/e/2PACX-1vSNgV3NVKlsgSOEsnUltswQgE8atWe1WCLUY5fQUVjEdu_JZcVlRkZcpbTOewwe3oBNa4l7IJlOnUIB/pub' rel='noreferrer' target='_blank'>privacy policy</a>.
</label>
</div>
<button className={classNames(blueButton, 'w-full')} type='submit'>Sign up</button>
<div className='flex justify-center'>
{recaptchaPublicKey && showRecaptcha ?
<ReCAPTCHA
onChange={(token) => onSubmit(token)}
ref={recaptchaRef}
sitekey={recaptchaPublicKey}
/>
:
<button className={classNames(blueButton, 'w-full')} onClick={() => onSubmit(null)}>Sign up</button>
}
</div>
</div>
</StepWizard>
<div className='flex flex-col gap-4 items-center'>
Expand All @@ -213,7 +251,7 @@ export default function SignupForm() {
</Link>
</div>
</div>
</form>
</div>
</FormTemplate>
);
}
63 changes: 33 additions & 30 deletions pages/[subdomain]/play-as-guest/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,26 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}

return {
props: { recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY || '' },
props: {
recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY ?? null,
},
};
}

interface PlayAsGuestProps {
recaptchaPublicKey: string | null;
}

/* istanbul ignore next */
export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey?: string}) {
export default function PlayAsGuest({ recaptchaPublicKey }: PlayAsGuestProps) {
const { cache } = useSWRConfig();
const { mutateUser, setShouldAttemptAuth, userConfig } = useContext(AppContext);
const [name, setName] = useState<string>('');
const recaptchaRef = useRef<ReCAPTCHA>(null);
const recaptchaToken = useRef('');
const [registrationState, setRegistrationState] = useState('registering');
const [showRecaptcha, setShowRecaptcha] = useState(false);
const [temporaryPassword, setTemporaryPassword] = useState<string>('');

function onRecaptchaChange(value: string | null) {
if (value) {
recaptchaToken.current = value;
setTimeout(fetchSignup, 50);
}
}

const CopyToClipboardButton = ({ text }: { text: string }) => {
const [isCopied, setIsCopied] = useState(false);

Expand Down Expand Up @@ -77,48 +75,54 @@ export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey?
);
};

async function fetchSignup() {
async function fetchSignup(recaptchaToken: string | null) {
if (recaptchaPublicKey) {
if (!showRecaptcha) {
setShowRecaptcha(true);

return;
}

if (!recaptchaToken.current) {
if (!recaptchaToken) {
toast.error('Please complete the recaptcha');

return;
}

if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
}

const tutorialCompletedAt = window.localStorage.getItem('tutorialCompletedAt') || '0';
const utm_source = window.localStorage.getItem('utm_source') || '';

toast.dismiss();
toast.loading('Creating guest account...');

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body: any = {
email: 'guest@guest.com',
guest: true,
name: 'Guest',
password: 'guest-account',
tutorialCompletedAt: tutorialCompletedAt,
utm_source: utm_source,
};

if (recaptchaToken) {
body.recaptchaToken = recaptchaToken;
}

const res = await fetch('/api/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',

body: JSON.stringify({
name: 'Guest',
email: 'guest@guest.com',
password: 'guest-account',
recaptchaToken: recaptchaToken.current,
guest: true,
tutorialCompletedAt: tutorialCompletedAt,
utm_source: utm_source
})
body: JSON.stringify(body)
});

if (recaptchaRef.current) {
recaptchaRef.current.reset();
}

if (!res.ok) {
toast.dismiss();
toast.error('Error creating guest account');
Expand Down Expand Up @@ -208,13 +212,12 @@ export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey?
</ul>
{recaptchaPublicKey && showRecaptcha ?
<ReCAPTCHA
size='normal'
onChange={onRecaptchaChange}
onChange={(token) => fetchSignup(token)}
ref={recaptchaRef}
sitekey={recaptchaPublicKey ?? ''}
sitekey={recaptchaPublicKey}
/>
:
<button className={classNames(blueButton, 'w-full')} onClick={fetchSignup}>
<button className={classNames(blueButton, 'w-full')} onClick={() => fetchSignup(null)}>
Play as guest
</button>
}
Expand Down
12 changes: 9 additions & 3 deletions pages/[subdomain]/signup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}

return {
props: {},
props: {
recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY ?? null,
},
};
}

interface SignUpProps {
recaptchaPublicKey: string | null;
}

/* istanbul ignore next */
export default function SignUp() {
export default function SignUp({ recaptchaPublicKey }: SignUpProps) {
return (
<Page title='Sign Up'>
<SignupForm />
<SignupForm recaptchaPublicKey={recaptchaPublicKey} />
</Page>
);
}
27 changes: 26 additions & 1 deletion pages/api/signup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,37 @@ export default apiWrapper({ POST: {
guest: ValidType('boolean', false),
name: ValidType('string'),
password: ValidType('string'),
recaptchaToken: ValidType('string', false),
tutorialCompletedAt: ValidNumber(false),
},
} }, async (req: NextApiRequestWrapper, res: NextApiResponse) => {
await dbConnect();

const { email, guest, name, password, tutorialCompletedAt, utm_source } = req.body;
const { email, guest, name, password, recaptchaToken, tutorialCompletedAt, utm_source } = req.body;

const RECAPTCHA_SECRET = process.env.RECAPTCHA_SECRET || '';

if (RECAPTCHA_SECRET && RECAPTCHA_SECRET.length > 0) {
if (!recaptchaToken) {
return res.status(400).json({ error: 'Please fill out recaptcha' });
}

const recaptchaResponse = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${RECAPTCHA_SECRET}&response=${recaptchaToken}`,
});

const recaptchaData = await recaptchaResponse.json();

if (!recaptchaResponse.ok || !recaptchaData?.success) {
const errorMessage = `Error validating recaptcha [Status: ${recaptchaResponse.status}], [Data: ${JSON.stringify(recaptchaData)}]`;

logger.error(errorMessage);

return res.status(400).json({ error: errorMessage });
}
}

let trimmedEmail: string, trimmedName: string, passwordValue: string;

Expand Down
68 changes: 68 additions & 0 deletions tests/pages/api/signup/signup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,74 @@ jest.mock('nodemailer', () => ({
describe('pages/api/signup', () => {
const cookie = getTokenCookieValue(TestId.USER);

test('Creating a user but not passing recaptcha should fail with 400', async () => {
process.env.RECAPTCHA_SECRET = 'defined';
jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger));
await testApiHandler({
pagesHandler: async (_, res) => {
const req: NextApiRequestWithAuth = {
method: 'POST',
cookies: {
token: cookie,
},
body: {
name: 'test_new',
email: 'test@gmail.com',
password: 'password',
},
headers: {
'content-type': 'application/json',
},
} as unknown as NextApiRequestWithAuth;

await signupUserHandler(req, res);
},
test: async ({ fetch }) => {
const res = await fetch();
const response = await res.json();

expect(response.error).toBe('Please fill out recaptcha');
expect(res.status).toBe(400);
},
});
});
test('Creating a user, pass recaptcha where fetch fails', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger));

process.env.RECAPTCHA_SECRET = 'defined';

// mock fetch failing with 400
fetchMock.mockResponseOnce(JSON.stringify({ 'mock': true }), { status: 408 });

await testApiHandler({
pagesHandler: async (_, res) => {
const req: NextApiRequestWithAuth = {
method: 'POST',
cookies: {
token: cookie,
},
body: {
name: 'test_new',
email: 'test@gmail.com',
password: 'password',
recaptchaToken: 'token',
},
headers: {
'content-type': 'application/json',
},
} as unknown as NextApiRequestWithAuth;

await signupUserHandler(req, res);
},
test: async ({ fetch }) => {
const res = await fetch();
const response = await res.json();

expect(response.error).toBe('Error validating recaptcha [Status: 408], [Data: {"mock":true}]');
expect(res.status).toBe(400);
},
});
});
test('Creating a user without a body should fail with 400', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger));
await testApiHandler({
Expand Down

0 comments on commit c715f0a

Please sign in to comment.