Skip to content

Commit 34cfc26

Browse files
authored
improvement(contact): add Turnstile CAPTCHA, honeypot, and robustness fixes (#4248)
* improvement(contact): add Turnstile CAPTCHA, honeypot, and robustness fixes - Add Cloudflare Turnstile with graceful degradation: when the widget fails to load (ad blockers, iOS privacy, corporate DNS), submissions fall through to a tighter rate-limit bucket rather than hard-blocking - Add honeypot field to filter automated submissions without user impact - Add separate CAPTCHA_UNAVAILABLE_RATE_LIMIT bucket (3/min) for the no-captcha path so spam via ad-blocker bypass remains expensive - Pass expectedHostname to verifyTurnstileToken to close cross-site token reuse gap - Add SITE_HOSTNAME as module-level constant (avoid URL parsing per req) - Wire onExpire/onError/onUnsupported callbacks so token expiry during slow form-filling falls back gracefully instead of showing a captcha error - Add getResponsePromise(30_000) timeout to prevent indefinite hang on network blips - Add size: 'invisible' to Turnstile options (required for execute mode) - Move turnstile.ts to lib/core/security/ alongside csp/encryption/input-validation - Switch all CSS to --landing-* variables throughout contact form - Move error display inline next to label with truncation in LandingField - Add labelClassName prop to LandingField for context-specific overrides - Simplify contact page to single-column max-w-[640px] layout * fix(contact): fall through to no-captcha rate limit on Cloudflare transport errors * chore(contact): remove extraneous comments from route * fix(contact): remove forced min-height on success state, let content flow naturally * fix(contact): cast CONTACT_TOPIC_OPTIONS to satisfy Combobox mutable type * fix(contact): disable submit during CAPTCHA resolution window, add relative to form
1 parent 2d94b37 commit 34cfc26

5 files changed

Lines changed: 382 additions & 143 deletions

File tree

apps/sim/app/(landing)/components/contact/contact-form.tsx

Lines changed: 161 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { useEffect, useRef, useState } from 'react'
4+
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
5+
import { toError } from '@sim/utils/errors'
46
import { useMutation } from '@tanstack/react-query'
5-
import { Combobox, Input, Textarea } from '@/components/emcn'
7+
import Link from 'next/link'
8+
import { Combobox, type ComboboxOption, Input, Textarea } from '@/components/emcn'
69
import { Check } from '@/components/emcn/icons'
7-
import { cn } from '@/lib/core/utils/cn'
10+
import { getEnv } from '@/lib/core/config/env'
811
import { captureClientEvent } from '@/lib/posthog/client'
912
import {
1013
CONTACT_TOPIC_OPTIONS,
@@ -34,12 +37,28 @@ const INITIAL_FORM_STATE: ContactFormState = {
3437
message: '',
3538
}
3639

37-
const COMBOBOX_TOPICS = [...CONTACT_TOPIC_OPTIONS]
38-
3940
const LANDING_INPUT =
40-
'h-[36px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
41+
'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
42+
43+
const LANDING_TEXTAREA =
44+
'min-h-[140px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
45+
46+
const LANDING_COMBOBOX =
47+
'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] hover:bg-[var(--landing-bg-surface)] focus-within:border-[var(--landing-border-strong)]'
48+
49+
const LANDING_SUBMIT =
50+
'flex h-[40px] w-full items-center justify-center rounded-[5px] border border-[var(--landing-text-subtle)] bg-[var(--landing-text-subtle)] font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[var(--landing-bg-hover)] hover:bg-[var(--landing-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60'
51+
52+
const LANDING_LABEL =
53+
'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'
4154

42-
async function submitContactRequest(payload: ContactRequestPayload) {
55+
interface SubmitContactRequestInput extends ContactRequestPayload {
56+
website: string
57+
captchaToken?: string
58+
captchaUnavailable?: boolean
59+
}
60+
61+
async function submitContactRequest(payload: SubmitContactRequestInput) {
4362
const response = await fetch('/api/contact', {
4463
method: 'POST',
4564
headers: { 'Content-Type': 'application/json' },
@@ -59,9 +78,7 @@ async function submitContactRequest(payload: ContactRequestPayload) {
5978
}
6079

6180
export function ContactForm() {
62-
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
63-
const [errors, setErrors] = useState<ContactErrors>({})
64-
const [submitSuccess, setSubmitSuccess] = useState(false)
81+
const turnstileRef = useRef<TurnstileInstance>(null)
6582

6683
const contactMutation = useMutation({
6784
mutationFn: submitContactRequest,
@@ -71,8 +88,23 @@ export function ContactForm() {
7188
setErrors({})
7289
setSubmitSuccess(true)
7390
},
91+
onError: () => {
92+
turnstileRef.current?.reset()
93+
},
7494
})
7595

96+
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
97+
const [errors, setErrors] = useState<ContactErrors>({})
98+
const [submitSuccess, setSubmitSuccess] = useState(false)
99+
const [isSubmitting, setIsSubmitting] = useState(false)
100+
const [website, setWebsite] = useState('')
101+
const [widgetReady, setWidgetReady] = useState(false)
102+
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()
103+
104+
useEffect(() => {
105+
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
106+
}, [])
107+
76108
function updateField<TField extends keyof ContactFormState>(
77109
field: TField,
78110
value: ContactFormState[TField]
@@ -91,9 +123,10 @@ export function ContactForm() {
91123
}
92124
}
93125

94-
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
126+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
95127
event.preventDefault()
96-
if (contactMutation.isPending) return
128+
if (contactMutation.isPending || isSubmitting) return
129+
setIsSubmitting(true)
97130

98131
const parsed = contactRequestSchema.safeParse({
99132
...form,
@@ -110,35 +143,55 @@ export function ContactForm() {
110143
subject: fieldErrors.subject?.[0],
111144
message: fieldErrors.message?.[0],
112145
})
146+
setIsSubmitting(false)
113147
return
114148
}
115149

116-
contactMutation.mutate(parsed.data)
150+
let captchaToken: string | undefined
151+
let captchaUnavailable: boolean | undefined
152+
const widget = turnstileRef.current
153+
154+
if (turnstileSiteKey) {
155+
if (widgetReady && widget) {
156+
try {
157+
widget.reset()
158+
widget.execute()
159+
captchaToken = await widget.getResponsePromise(30_000)
160+
} catch {
161+
captchaUnavailable = true
162+
}
163+
} else {
164+
captchaUnavailable = true
165+
}
166+
}
167+
168+
contactMutation.mutate({ ...parsed.data, website, captchaToken, captchaUnavailable })
169+
setIsSubmitting(false)
117170
}
118171

172+
const isBusy = contactMutation.isPending || isSubmitting
173+
119174
const submitError = contactMutation.isError
120-
? contactMutation.error instanceof Error
121-
? contactMutation.error.message
122-
: 'Failed to send message. Please try again.'
175+
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
123176
: null
124177

125178
if (submitSuccess) {
126179
return (
127-
<div className='flex min-h-[460px] flex-col items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] px-8 py-16 text-center'>
128-
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg-subtle)] text-[var(--text-primary)]'>
180+
<div className='flex flex-col items-center px-8 py-16 text-center'>
181+
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] text-[var(--landing-text)]'>
129182
<Check className='h-8 w-8' />
130183
</div>
131-
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--text-primary)] leading-[1.2] tracking-[-0.02em]'>
184+
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--landing-text)] leading-[1.2] tracking-[-0.02em]'>
132185
Message received
133186
</h2>
134-
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary)] leading-[1.6]'>
187+
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--landing-text-body)] leading-[1.6]'>
135188
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
136189
shortly.
137190
</p>
138191
<button
139192
type='button'
140193
onClick={() => setSubmitSuccess(false)}
141-
className='mt-6 font-season text-[13px] text-[var(--text-primary)] underline underline-offset-2 transition-opacity hover:opacity-80'
194+
className='mt-6 font-season text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
142195
>
143196
Send another message
144197
</button>
@@ -147,12 +200,33 @@ export function ContactForm() {
147200
}
148201

149202
return (
150-
<form
151-
onSubmit={handleSubmit}
152-
className='flex flex-col gap-4 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] p-6 sm:p-8'
153-
>
154-
<div className='grid gap-4 sm:grid-cols-2'>
155-
<LandingField htmlFor='contact-name' label='Name' error={errors.name}>
203+
<form onSubmit={handleSubmit} className='relative flex flex-col gap-5'>
204+
{/* Honeypot */}
205+
<div
206+
aria-hidden='true'
207+
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
208+
>
209+
<label htmlFor='contact-website'>Website</label>
210+
<input
211+
id='contact-website'
212+
name='website'
213+
type='text'
214+
tabIndex={-1}
215+
autoComplete='off'
216+
value={website}
217+
onChange={(event) => setWebsite(event.target.value)}
218+
data-lpignore='true'
219+
data-1p-ignore='true'
220+
/>
221+
</div>
222+
223+
<div className='grid gap-5 sm:grid-cols-2'>
224+
<LandingField
225+
htmlFor='contact-name'
226+
label='Name'
227+
error={errors.name}
228+
labelClassName={LANDING_LABEL}
229+
>
156230
<Input
157231
id='contact-name'
158232
value={form.name}
@@ -161,7 +235,12 @@ export function ContactForm() {
161235
className={LANDING_INPUT}
162236
/>
163237
</LandingField>
164-
<LandingField htmlFor='contact-email' label='Email' error={errors.email}>
238+
<LandingField
239+
htmlFor='contact-email'
240+
label='Email'
241+
error={errors.email}
242+
labelClassName={LANDING_LABEL}
243+
>
165244
<Input
166245
id='contact-email'
167246
type='email'
@@ -173,8 +252,14 @@ export function ContactForm() {
173252
</LandingField>
174253
</div>
175254

176-
<div className='grid gap-4 sm:grid-cols-2'>
177-
<LandingField htmlFor='contact-company' label='Company' optional error={errors.company}>
255+
<div className='grid gap-5 sm:grid-cols-2'>
256+
<LandingField
257+
htmlFor='contact-company'
258+
label='Company'
259+
optional
260+
error={errors.company}
261+
labelClassName={LANDING_LABEL}
262+
>
178263
<Input
179264
id='contact-company'
180265
value={form.company}
@@ -183,21 +268,31 @@ export function ContactForm() {
183268
className={LANDING_INPUT}
184269
/>
185270
</LandingField>
186-
<LandingField htmlFor='contact-topic' label='Topic' error={errors.topic}>
271+
<LandingField
272+
htmlFor='contact-topic'
273+
label='Topic'
274+
error={errors.topic}
275+
labelClassName={LANDING_LABEL}
276+
>
187277
<Combobox
188-
options={COMBOBOX_TOPICS}
278+
options={CONTACT_TOPIC_OPTIONS as unknown as ComboboxOption[]}
189279
value={form.topic}
190280
selectedValue={form.topic}
191281
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
192282
placeholder='Select a topic'
193283
editable={false}
194284
filterOptions={false}
195-
className='h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]'
285+
className={LANDING_COMBOBOX}
196286
/>
197287
</LandingField>
198288
</div>
199289

200-
<LandingField htmlFor='contact-subject' label='Subject' error={errors.subject}>
290+
<LandingField
291+
htmlFor='contact-subject'
292+
label='Subject'
293+
error={errors.subject}
294+
labelClassName={LANDING_LABEL}
295+
>
201296
<Input
202297
id='contact-subject'
203298
value={form.subject}
@@ -207,33 +302,53 @@ export function ContactForm() {
207302
/>
208303
</LandingField>
209304

210-
<LandingField htmlFor='contact-message' label='Message' error={errors.message}>
305+
<LandingField
306+
htmlFor='contact-message'
307+
label='Message'
308+
error={errors.message}
309+
labelClassName={LANDING_LABEL}
310+
>
211311
<Textarea
212312
id='contact-message'
213313
value={form.message}
214314
onChange={(event) => updateField('message', event.target.value)}
215315
placeholder='Share details so we can help as quickly as possible'
216-
className='min-h-[140px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
316+
className={LANDING_TEXTAREA}
217317
/>
218318
</LandingField>
219319

320+
{turnstileSiteKey ? (
321+
<Turnstile
322+
ref={turnstileRef}
323+
siteKey={turnstileSiteKey}
324+
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
325+
onWidgetLoad={() => setWidgetReady(true)}
326+
onExpire={() => setWidgetReady(false)}
327+
onError={() => setWidgetReady(false)}
328+
onUnsupported={() => setWidgetReady(false)}
329+
/>
330+
) : null}
331+
220332
{submitError ? (
221333
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
222334
{submitError}
223335
</p>
224336
) : null}
225337

226-
<button
227-
type='submit'
228-
disabled={contactMutation.isPending}
229-
className={cn(
230-
'flex h-[40px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)]',
231-
'font-[430] font-season text-[14px] text-[var(--bg)] transition-opacity',
232-
'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60'
233-
)}
234-
>
235-
{contactMutation.isPending ? 'Sending...' : 'Send message'}
338+
<button type='submit' disabled={isBusy} className={LANDING_SUBMIT}>
339+
{isBusy ? 'Sending...' : 'Send message'}
236340
</button>
341+
342+
<p className='text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]'>
343+
By submitting, you agree to our{' '}
344+
<Link
345+
href='/privacy'
346+
className='text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
347+
>
348+
Privacy Policy
349+
</Link>
350+
.
351+
</p>
237352
</form>
238353
)
239354
}

apps/sim/app/(landing)/components/forms/landing-field.tsx

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,44 @@ interface LandingFieldProps {
66
optional?: boolean
77
error?: string
88
children: React.ReactNode
9+
/** Replaces the default label className. */
10+
labelClassName?: string
911
}
1012

11-
export function LandingField({ label, htmlFor, optional, error, children }: LandingFieldProps) {
13+
const DEFAULT_LABEL_CLASSNAME =
14+
'font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
15+
16+
export function LandingField({
17+
label,
18+
htmlFor,
19+
optional,
20+
error,
21+
children,
22+
labelClassName,
23+
}: LandingFieldProps) {
1224
const errorId = error ? `${htmlFor}-error` : undefined
1325
const describedChild =
1426
errorId && isValidElement<{ 'aria-describedby'?: string; 'aria-invalid'?: boolean }>(children)
1527
? cloneElement(children, { 'aria-describedby': errorId, 'aria-invalid': true })
1628
: children
1729
return (
1830
<div className='flex flex-col gap-1.5'>
19-
<label
20-
htmlFor={htmlFor}
21-
className='font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
22-
>
23-
{label}
24-
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
25-
</label>
31+
<div className='flex min-h-[18px] items-baseline justify-between gap-3'>
32+
<label htmlFor={htmlFor} className={labelClassName ?? DEFAULT_LABEL_CLASSNAME}>
33+
{label}
34+
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
35+
</label>
36+
{error ? (
37+
<span
38+
id={errorId}
39+
role='alert'
40+
className='truncate font-season text-[12px] text-[var(--text-error)]'
41+
>
42+
{error}
43+
</span>
44+
) : null}
45+
</div>
2646
{describedChild}
27-
{error ? (
28-
<p id={errorId} role='alert' className='text-[12px] text-[var(--text-error)]'>
29-
{error}
30-
</p>
31-
) : null}
3247
</div>
3348
)
3449
}

0 commit comments

Comments
 (0)