diff --git a/components/forms/signupFormWizard.tsx b/components/forms/signupFormWizard.tsx new file mode 100644 index 000000000..9e381d772 --- /dev/null +++ b/components/forms/signupFormWizard.tsx @@ -0,0 +1,198 @@ +import { useRouter } from 'next/router'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import toast from 'react-hot-toast'; +import StepWizard, { StepWizardProps } from 'react-step-wizard'; +import { useSWRConfig } from 'swr'; +import { debounce } from 'throttle-debounce'; +import { AppContext } from '../../contexts/appContext'; +import LoadingSpinner from '../page/loadingSpinner'; +import FormTemplate from './formTemplate'; + +export default function SignupFormWizard() { + const { cache } = useSWRConfig(); + const [email, setEmail] = useState(''); + const { mutateUser, setShouldAttemptAuth } = useContext(AppContext); + const [password, setPassword] = useState(''); + const recaptchaRef = useRef(null); + const router = useRouter(); + const [username, setUsername] = useState(''); + + function onSubmit(event: React.FormEvent) { + event.preventDefault(); + + if (password.length < 8 || password.length > 50) { + toast.dismiss(); + toast.error('Password must be between 8 and 50 characters'); + + return; + } + + if (username.match(/[^-a-zA-Z0-9_]/)) { + toast.dismiss(); + toast.error('Username can only contain letters, numbers, underscores, and hyphens'); + + return; + } + + toast.dismiss(); + toast.loading('Registering...'); + + const tutorialCompletedAt = window.localStorage.getItem('tutorialCompletedAt') || '0'; + const utm_source = window.localStorage.getItem('utm_source') || ''; + + fetch('/api/signup', { + method: 'POST', + body: JSON.stringify({ + email: email, + name: username, + password: password, + tutorialCompletedAt: parseInt(tutorialCompletedAt), + utm_source: utm_source + }), + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + } + }).then(async res => { + if (recaptchaRef.current) { + recaptchaRef.current.reset(); + } + + if (res.status === 200) { + const resObj = await res.json(); + + if (resObj.sentMessage) { + toast.dismiss(); + toast.error('An account with this email already exists! Please check your email to set your password.'); + } else { + // clear cache + for (const key of cache.keys()) { + cache.delete(key); + } + + toast.dismiss(); + toast.success('Registered! Please confirm your email.'); + + // clear localstorage value + window.localStorage.removeItem('tutorialCompletedAt'); + mutateUser(); + setShouldAttemptAuth(true); + sessionStorage.clear(); + router.push('/confirm-email'); + } + } else { + throw res.text(); + } + }).catch(async err => { + console.error(err); + toast.dismiss(); + toast.error(JSON.parse(await err)?.error); + }); + } + + const [isValidUsername, setIsValidUsername] = useState(true); + const [wizard, setWizard] = useState(); + const isLoadingExistsCheck = useRef(false); + // let's check if username exists already when user types + const checkUsername = async (username: string) => { + if (username.length < 3 || username.length > 50) { + setIsValidUsername(false); + + return; + } + + const res = await fetch(`/api/user/exists?name=${username}`); + + isLoadingExistsCheck.current = false; + setIsValidUsername(res.status === 404); + }; + + // debounce the checkUsername function + const debouncedCheckUsername = useRef( + debounce(500, checkUsername) + ).current; + + // check if username is valid + const handleUsernameChange = (e: React.ChangeEvent) => { + setUsername(e.target.value); + isLoadingExistsCheck.current = true; + debouncedCheckUsername(e.target.value); + }; + + useEffect(() => { + if (username.length < 3 || username.length > 50) { + setIsValidUsername(false); + + return; + } + + if (username.match(/[^-a-zA-Z0-9_]/)) { + setIsValidUsername(false); + + return; + } + }, [username]); + + return ( + + +
+ +
+ + handleUsernameChange(e)} className='shadow appearance-none border rounded w-full py-2 px-3 leading-tight focus:outline-none focus:shadow-outline' id='username' type='text' placeholder='Username' /> + + { username.length >= 3 && ( +
+ {username.length > 0 && !isLoadingExistsCheck.current && ( + + {isValidUsername ? '✅' : '❌'} + + )} + + { isLoadingExistsCheck.current ? : ( + isValidUsername ? 'Username is available' : 'Username is not available' + )} + +
+ )} +
+
+

+ Nice to meet you, {username}! +

+

+ Your Thinky.gg journey is about to launch! 🚀 +

+ + setEmail(e.target.value)} value={email} className='shadow appearance-none border rounded w-full py-2 px-3 leading-tight focus:outline-none focus:shadow-outline' id='email' type='email' placeholder='Email' /> +
+ + setPassword(e.target.value)} className='shadow appearance-none border rounded w-full py-2 px-3 leading-tight focus:outline-none focus:shadow-outline' id='password' type='password' placeholder='******************' /> +
+ + +
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/components/page/loadingSpinner.tsx b/components/page/loadingSpinner.tsx index ac1f89f29..1e735c436 100644 --- a/components/page/loadingSpinner.tsx +++ b/components/page/loadingSpinner.tsx @@ -1,6 +1,23 @@ import React from 'react'; -export default function LoadingSpinner() { +interface LoadingSpinnerProps { + size?: 'small' | 'medium' | 'large'; +} + +export default function LoadingSpinner({ size }: LoadingSpinnerProps) { + if (size === 'small') { + return + + + + ; + } + return (

Check your spam folder or click here to resend (or update your email).

- Once you have confirmed your email, you will be redirected + Once you have confirmed your email, you will be redirected automatically.

diff --git a/pages/[subdomain]/signup/index.tsx b/pages/[subdomain]/signup/index.tsx index 83de940bd..4c11461c6 100644 --- a/pages/[subdomain]/signup/index.tsx +++ b/pages/[subdomain]/signup/index.tsx @@ -1,28 +1,33 @@ +import SignupFormWizard from '@root/components/forms/signupFormWizard'; import GameLogoAndLabel from '@root/components/gameLogoAndLabel'; import { GameId } from '@root/constants/GameId'; -import { GetServerSidePropsContext } from 'next'; +import { getUserFromToken } from '@root/lib/withAuth'; +import { GetServerSidePropsContext, NextApiRequest } from 'next'; import Link from 'next/link'; import React from 'react'; -import SignupForm from '../../../components/forms/signupForm'; import Page from '../../../components/page/page'; -import redirectToHome from '../../../helpers/redirectToHome'; export async function getServerSideProps(context: GetServerSidePropsContext) { - const redirect = await redirectToHome(context); + const token = context.req?.cookies?.token; + const reqUser = token ? await getUserFromToken(token, context.req as NextApiRequest) : null; - if (redirect.redirect) { - return redirect; + if (reqUser) { + return { + redirect: { + destination: '/', + permanent: false, + }, + }; } return { props: { - recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY || '', }, }; } /* istanbul ignore next */ -export default function SignUp({ recaptchaPublicKey }: {recaptchaPublicKey?: string}) { +export default function SignUp() { return ( <> @@ -34,12 +39,24 @@ export default function SignUp({ recaptchaPublicKey }: {recaptchaPublicKey?: str
Create a Thinky.gg account and start playing!
Your Thinky.gg account works across all games on the site.
- -
- {'Already have an account? '} - + +
+
+ + Play as Guest + +
+
+ {'Already have an account? '} + Log In - + + +
+
diff --git a/pages/api/signup/index.ts b/pages/api/signup/index.ts index 4d7fa1807..6a8a930cc 100644 --- a/pages/api/signup/index.ts +++ b/pages/api/signup/index.ts @@ -68,37 +68,12 @@ export default apiWrapper({ POST: { email: ValidType('string'), name: ValidType('string'), password: ValidType('string'), - recaptchaToken: ValidType('string', false), tutorialCompletedAt: ValidNumber(false), }, } }, async (req: NextApiRequestWrapper, res: NextApiResponse) => { await dbConnect(); - const { email, name, password, tutorialCompletedAt, recaptchaToken, guest, 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 }); - } - } + const { email, name, password, tutorialCompletedAt, guest, utm_source } = req.body; let trimmedEmail: string, trimmedName: string, passwordValue: string; diff --git a/pages/api/user/exists.ts b/pages/api/user/exists.ts new file mode 100644 index 000000000..f8aff4d53 --- /dev/null +++ b/pages/api/user/exists.ts @@ -0,0 +1,16 @@ +import apiWrapper, { NextApiRequestWrapper, ValidType } from '@root/helpers/apiWrapper'; +import { UserModel } from '@root/models/mongoose'; +import { NextApiResponse } from 'next'; + +export default apiWrapper({ + GET: { + query: { + name: ValidType('string', true), + } + } +}, async (req: NextApiRequestWrapper, res: NextApiResponse) => { + const { name } = req.query as { name: string }; + const userExists = await UserModel.exists({ name }); + + return res.status(userExists ? 200 : 404).json({ exists: userExists }); +}); diff --git a/styles/global.css b/styles/global.css index 66cbd927b..82b3de5e3 100644 --- a/styles/global.css +++ b/styles/global.css @@ -587,4 +587,4 @@ body { */ #headlessui-portal-root > [data-headlessui-portal]:nth-child(2) > div > div > div:first-child { user-select: none; -} +} \ No newline at end of file diff --git a/tests/pages/api/signup/signup.test.ts b/tests/pages/api/signup/signup.test.ts index 944852ef3..827b216d4 100644 --- a/tests/pages/api/signup/signup.test.ts +++ b/tests/pages/api/signup/signup.test.ts @@ -39,74 +39,6 @@ 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({