Add web recaptcha on signup

This commit is contained in:
Jackson Harper
2024-03-31 23:58:21 +08:00
parent 7518e164a5
commit 2257a5a42e
4 changed files with 237 additions and 180 deletions

View File

@ -1,7 +1,7 @@
import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText, StyledTextSpan } from '../../elements/StyledText'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { BorderedFormInput, FormLabel } from '../../elements/FormElements'
import { TermAndConditionsFooter } from '../LoginForm'
import { fetchEndpoint } from '../../../lib/appConfig'
@ -10,26 +10,19 @@ import { logoutMutation } from '../../../lib/networking/mutations/logoutMutation
import { useRouter } from 'next/router'
import { formatMessage } from '../../../locales/en/messages'
import { parseErrorCodes } from '../../../lib/queryParamParser'
import {
GoogleReCaptchaProvider,
GoogleReCaptchaCheckbox,
} from '@google-recaptcha/react'
import Link from 'next/link'
export function EmailSignup(): JSX.Element {
const router = useRouter()
const SignUpForm = (): JSX.Element => {
const [email, setEmail] = useState<string | undefined>()
const [password, setPassword] = useState<string | undefined>()
const [fullname, setFullname] = useState<string | undefined>()
const [username, setUsername] = useState<string | undefined>()
const [debouncedUsername, setDebouncedUsername] =
useState<string | undefined>()
const [errorMessage, setErrorMessage] = useState<string | undefined>()
useEffect(() => {
if (!router.isReady) return
const errorCode = parseErrorCodes(router.query)
const errorMsg = errorCode
? formatMessage({ id: `error.${errorCode}` })
: undefined
setErrorMessage(errorMsg)
}, [router.isReady, router.query])
const { isUsernameValid, usernameErrorMessage } = useValidateUsernameQuery({
username: debouncedUsername ?? '',
@ -46,174 +39,228 @@ export function EmailSignup(): JSX.Element {
)
return (
<form action={`${fetchEndpoint}/auth/email-signup`} method="POST">
<VStack
alignment="center"
css={{
padding: '16px',
background: 'white',
minWidth: '340px',
width: '70vw',
maxWidth: '576px',
borderRadius: '8px',
border: '1px solid #3D3D3D',
boxShadow: '#B1B1B1 9px 9px 9px -9px',
}}
>
<StyledText style="subHeadline" css={{ color: '$omnivoreGray' }}>
Sign Up
</StyledText>
<VStack
css={{ width: '100%', minWidth: '320px', gap: '16px', pb: '16px' }}
>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Email</FormLabel>
<BorderedFormInput
autoFocus={true}
key="email"
type="email"
name="email"
defaultValue={email}
placeholder="Email"
css={{ backgroundColor: 'white', color: 'black' }}
onChange={(e) => {
e.preventDefault()
setEmail(e.target.value)
}}
required
/>
</SpanBox>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Password</FormLabel>
<BorderedFormInput
key="password"
type="password"
name="password"
defaultValue={password}
placeholder="Password"
css={{ bg: 'white', color: 'black' }}
onChange={(e) => setPassword(e.target.value)}
required
/>
</SpanBox>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Full Name</FormLabel>
<BorderedFormInput
key="fullname"
type="text"
name="name"
defaultValue={fullname}
placeholder="Full Name"
css={{ bg: 'white', color: 'black' }}
onChange={(e) => setFullname(e.target.value)}
required
/>
</SpanBox>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Username</FormLabel>
<BorderedFormInput
key="username"
type="text"
name="username"
defaultValue={username}
placeholder="Username"
css={{ bg: 'white', color: 'black' }}
onChange={handleUsernameChange}
required
/>
</SpanBox>
{username && username.length > 0 && usernameErrorMessage && (
<StyledText
style="caption"
css={{
m: 0,
pl: '$2',
color: '$error',
alignSelf: 'flex-start',
}}
>
{usernameErrorMessage}
</StyledText>
)}
{isUsernameValid && (
<StyledText
style="caption"
css={{
m: 0,
pl: '$2',
alignSelf: 'flex-start',
color: '$omnivoreGray',
}}
>
Username is available.
</StyledText>
)}
</VStack>
{errorMessage && <StyledText style="error">{errorMessage}</StyledText>}
<VStack css={{ width: '100%', minWidth: '320px', gap: '16px', pb: '16px' }}>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Email</FormLabel>
<BorderedFormInput
autoFocus={true}
key="email"
type="email"
name="email"
defaultValue={email}
placeholder="Email"
css={{ backgroundColor: 'white', color: 'black' }}
onChange={(e) => {
e.preventDefault()
setEmail(e.target.value)
}}
required
/>
</SpanBox>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Password</FormLabel>
<BorderedFormInput
key="password"
type="password"
name="password"
defaultValue={password}
placeholder="Password"
css={{ bg: 'white', color: 'black' }}
onChange={(e) => setPassword(e.target.value)}
required
/>
</SpanBox>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Full Name</FormLabel>
<BorderedFormInput
key="fullname"
type="text"
name="name"
defaultValue={fullname}
placeholder="Full Name"
css={{ bg: 'white', color: 'black' }}
onChange={(e) => setFullname(e.target.value)}
required
/>
</SpanBox>
<SpanBox css={{ width: '100%' }}>
<FormLabel className="required">Username</FormLabel>
<BorderedFormInput
key="username"
type="text"
name="username"
defaultValue={username}
placeholder="Username"
css={{ bg: 'white', color: 'black' }}
onChange={handleUsernameChange}
required
/>
</SpanBox>
{username && username.length > 0 && usernameErrorMessage && (
<StyledText
style="caption"
css={{
p: '0px',
color: '$omnivoreLightGray',
m: 0,
pl: '$2',
color: '$error',
alignSelf: 'flex-start',
}}
>
Omnivore will send you daily tips for your first week as a new user.
If you don&apos;t like them you can unsubscribe.
{usernameErrorMessage}
</StyledText>
<HStack
alignment="center"
distribution="end"
css={{
gap: '10px',
width: '100%',
height: '80px',
}}
>
<Button
style={'ctaOutlineYellow'}
css={{ color: '$omnivoreGray', borderColor: 'rgba(0, 0, 0, 0.06)' }}
type="button"
onClick={async () => {
window.localStorage.removeItem('authVerified')
window.localStorage.removeItem('authToken')
try {
await logoutMutation()
} catch (e) {
console.log('error logging out', e)
}
window.location.href = '/'
}}
>
Cancel
</Button>
<Button type="submit" style={'ctaDarkYellow'}>
Sign Up
</Button>
</HStack>
)}
{isUsernameValid && (
<StyledText
style="action"
style="caption"
css={{
pt: '16px',
color: '$omnivoreLightGray',
textAlign: 'center',
m: 0,
pl: '$2',
alignSelf: 'flex-start',
color: '$omnivoreGray',
}}
>
Already have an account?{' '}
<Link href="/auth/email-login" passHref legacyBehavior>
<StyledTextSpan style="actionLink" css={{ color: '$omnivoreGray' }}>
Login instead
</StyledTextSpan>
</Link>
Username is available.
</StyledText>
<TermAndConditionsFooter />
</VStack>
</form>
)}
</VStack>
)
}
type RecaptchaProps = {
setRecaptchaToken: (string) => void
}
const Recaptcha = (props: RecaptchaProps): JSX.Element => {
return (
<>
<GoogleReCaptchaCheckbox
key="recaptcha"
onChange={(token) => {
console.log('recaptcha: ', token)
props.setRecaptchaToken(token)
}}
/>
</>
)
}
export function EmailSignup(): JSX.Element {
const router = useRouter()
const recaptchaTokenRef = useRef<HTMLInputElement>(null)
const [errorMessage, setErrorMessage] = useState<string | undefined>()
useEffect(() => {
if (!router.isReady) return
const errorCode = parseErrorCodes(router.query)
const errorMsg = errorCode
? formatMessage({ id: `error.${errorCode}` })
: undefined
setErrorMessage(errorMsg)
}, [router.isReady, router.query])
return (
<>
<form action={`${fetchEndpoint}/auth/email-signup`} method="POST">
<VStack
alignment="center"
css={{
padding: '16px',
background: 'white',
minWidth: '340px',
width: '70vw',
maxWidth: '576px',
borderRadius: '8px',
border: '1px solid #3D3D3D',
boxShadow: '#B1B1B1 9px 9px 9px -9px',
}}
>
<StyledText style="subHeadline" css={{ color: '$omnivoreGray' }}>
Sign Up
</StyledText>
<SignUpForm />
<Recaptcha
setRecaptchaToken={(token) => {
if (recaptchaTokenRef.current) {
recaptchaTokenRef.current.value = token
} else {
console.log('error updating recaptcha token')
}
}}
/>
<input ref={recaptchaTokenRef} type="hidden" name="recaptchaToken" />
{errorMessage && (
<StyledText style="error">{errorMessage}</StyledText>
)}
<StyledText
style="caption"
css={{
p: '0px',
color: '$omnivoreLightGray',
}}
>
Omnivore will send you daily tips for your first week as a new user.
If you don&apos;t like them you can unsubscribe.
</StyledText>
<HStack
alignment="center"
distribution="end"
css={{
gap: '10px',
width: '100%',
height: '80px',
}}
>
<Button
style={'ctaOutlineYellow'}
css={{
color: '$omnivoreGray',
borderColor: 'rgba(0, 0, 0, 0.06)',
}}
type="button"
onClick={async () => {
window.localStorage.removeItem('authVerified')
window.localStorage.removeItem('authToken')
try {
await logoutMutation()
} catch (e) {
console.log('error logging out', e)
}
window.location.href = '/'
}}
>
Cancel
</Button>
<Button type="submit" style={'ctaDarkYellow'}>
Sign Up
</Button>
</HStack>
<StyledText
style="action"
css={{
pt: '16px',
color: '$omnivoreLightGray',
textAlign: 'center',
}}
>
Already have an account?{' '}
<Link href="/auth/email-login" passHref legacyBehavior>
<StyledTextSpan
style="actionLink"
css={{ color: '$omnivoreGray' }}
>
Login instead
</StyledTextSpan>
</Link>
</StyledText>
<TermAndConditionsFooter />
</VStack>
</form>
</>
)
}

View File

@ -1,13 +1,13 @@
const ContentSecurityPolicy = `
default-src 'self';
base-uri 'self';
connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://api.segment.io https://cdn.segment.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com;
connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com;
font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com;
form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com;
frame-ancestors 'none';
frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com;
frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;
manifest-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.segment.com;
script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
style-src 'self' 'unsafe-inline' https://accounts.google.com https://cdnjs.cloudflare.com;
img-src 'self' blob: data: https:;
worker-src 'self' blob:;

View File

@ -17,6 +17,7 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.9",
"@google-recaptcha/react": "^1.0.3",
"@radix-ui/react-avatar": "^0.1.1",
"@radix-ui/react-checkbox": "^0.1.5",
"@radix-ui/react-dialog": "1.0.5",
@ -106,4 +107,4 @@
"volta": {
"extends": "../../package.json"
}
}
}

View File

@ -1,15 +1,24 @@
import { PageMetaData } from '../../components/patterns/PageMetaData'
import { ProfileLayout } from '../../components/templates/ProfileLayout'
import { EmailSignup } from '../../components/templates/auth/EmailSignup'
import { GoogleReCaptchaProvider } from '@google-recaptcha/react'
export default function EmailRegistrationPage(): JSX.Element {
return (
<>
<PageMetaData title="Sign up with Email - Omnivore" path="/auth-signup" />
<ProfileLayout>
<EmailSignup />
</ProfileLayout>
<div data-testid="auth-signup-page-tag" />
<GoogleReCaptchaProvider
type="v2-checkbox"
isEnterprise={true}
siteKey={process.env.NEXT_PUBLIC_RECAPTCHA_CHALLENGE_SECRET_KEY ?? ''}
>
<PageMetaData
title="Sign up with Email - Omnivore"
path="/auth-signup"
/>
<ProfileLayout>
<EmailSignup />
</ProfileLayout>
</GoogleReCaptchaProvider>
</>
)
}