Skip to content

Commit add43a8

Browse files
committed
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
1 parent aee6189 commit add43a8

5 files changed

Lines changed: 358 additions & 140 deletions

File tree

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

Lines changed: 152 additions & 43 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'
7+
import Link from 'next/link'
58
import { Combobox, 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,22 @@ 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 [website, setWebsite] = useState('')
100+
const [widgetReady, setWidgetReady] = useState(false)
101+
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()
102+
103+
useEffect(() => {
104+
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
105+
}, [])
106+
76107
function updateField<TField extends keyof ContactFormState>(
77108
field: TField,
78109
value: ContactFormState[TField]
@@ -91,7 +122,7 @@ export function ContactForm() {
91122
}
92123
}
93124

94-
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
125+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
95126
event.preventDefault()
96127
if (contactMutation.isPending) return
97128

@@ -113,32 +144,48 @@ export function ContactForm() {
113144
return
114145
}
115146

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

119168
const submitError = contactMutation.isError
120-
? contactMutation.error instanceof Error
121-
? contactMutation.error.message
122-
: 'Failed to send message. Please try again.'
169+
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
123170
: null
124171

125172
if (submitSuccess) {
126173
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)]'>
174+
<div className='flex min-h-[460px] flex-col items-center justify-center px-8 py-16 text-center'>
175+
<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)]'>
129176
<Check className='h-8 w-8' />
130177
</div>
131-
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--text-primary)] leading-[1.2] tracking-[-0.02em]'>
178+
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--landing-text)] leading-[1.2] tracking-[-0.02em]'>
132179
Message received
133180
</h2>
134-
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary)] leading-[1.6]'>
181+
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--landing-text-body)] leading-[1.6]'>
135182
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
136183
shortly.
137184
</p>
138185
<button
139186
type='button'
140187
onClick={() => setSubmitSuccess(false)}
141-
className='mt-6 font-season text-[13px] text-[var(--text-primary)] underline underline-offset-2 transition-opacity hover:opacity-80'
188+
className='mt-6 font-season text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
142189
>
143190
Send another message
144191
</button>
@@ -147,12 +194,33 @@ export function ContactForm() {
147194
}
148195

149196
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}>
197+
<form onSubmit={handleSubmit} className='flex flex-col gap-5'>
198+
{/* Honeypot */}
199+
<div
200+
aria-hidden='true'
201+
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
202+
>
203+
<label htmlFor='contact-website'>Website</label>
204+
<input
205+
id='contact-website'
206+
name='website'
207+
type='text'
208+
tabIndex={-1}
209+
autoComplete='off'
210+
value={website}
211+
onChange={(event) => setWebsite(event.target.value)}
212+
data-lpignore='true'
213+
data-1p-ignore='true'
214+
/>
215+
</div>
216+
217+
<div className='grid gap-5 sm:grid-cols-2'>
218+
<LandingField
219+
htmlFor='contact-name'
220+
label='Name'
221+
error={errors.name}
222+
labelClassName={LANDING_LABEL}
223+
>
156224
<Input
157225
id='contact-name'
158226
value={form.name}
@@ -161,7 +229,12 @@ export function ContactForm() {
161229
className={LANDING_INPUT}
162230
/>
163231
</LandingField>
164-
<LandingField htmlFor='contact-email' label='Email' error={errors.email}>
232+
<LandingField
233+
htmlFor='contact-email'
234+
label='Email'
235+
error={errors.email}
236+
labelClassName={LANDING_LABEL}
237+
>
165238
<Input
166239
id='contact-email'
167240
type='email'
@@ -173,8 +246,14 @@ export function ContactForm() {
173246
</LandingField>
174247
</div>
175248

176-
<div className='grid gap-4 sm:grid-cols-2'>
177-
<LandingField htmlFor='contact-company' label='Company' optional error={errors.company}>
249+
<div className='grid gap-5 sm:grid-cols-2'>
250+
<LandingField
251+
htmlFor='contact-company'
252+
label='Company'
253+
optional
254+
error={errors.company}
255+
labelClassName={LANDING_LABEL}
256+
>
178257
<Input
179258
id='contact-company'
180259
value={form.company}
@@ -183,21 +262,31 @@ export function ContactForm() {
183262
className={LANDING_INPUT}
184263
/>
185264
</LandingField>
186-
<LandingField htmlFor='contact-topic' label='Topic' error={errors.topic}>
265+
<LandingField
266+
htmlFor='contact-topic'
267+
label='Topic'
268+
error={errors.topic}
269+
labelClassName={LANDING_LABEL}
270+
>
187271
<Combobox
188-
options={COMBOBOX_TOPICS}
272+
options={CONTACT_TOPIC_OPTIONS}
189273
value={form.topic}
190274
selectedValue={form.topic}
191275
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
192276
placeholder='Select a topic'
193277
editable={false}
194278
filterOptions={false}
195-
className='h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]'
279+
className={LANDING_COMBOBOX}
196280
/>
197281
</LandingField>
198282
</div>
199283

200-
<LandingField htmlFor='contact-subject' label='Subject' error={errors.subject}>
284+
<LandingField
285+
htmlFor='contact-subject'
286+
label='Subject'
287+
error={errors.subject}
288+
labelClassName={LANDING_LABEL}
289+
>
201290
<Input
202291
id='contact-subject'
203292
value={form.subject}
@@ -207,33 +296,53 @@ export function ContactForm() {
207296
/>
208297
</LandingField>
209298

210-
<LandingField htmlFor='contact-message' label='Message' error={errors.message}>
299+
<LandingField
300+
htmlFor='contact-message'
301+
label='Message'
302+
error={errors.message}
303+
labelClassName={LANDING_LABEL}
304+
>
211305
<Textarea
212306
id='contact-message'
213307
value={form.message}
214308
onChange={(event) => updateField('message', event.target.value)}
215309
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)]'
310+
className={LANDING_TEXTAREA}
217311
/>
218312
</LandingField>
219313

314+
{turnstileSiteKey ? (
315+
<Turnstile
316+
ref={turnstileRef}
317+
siteKey={turnstileSiteKey}
318+
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
319+
onWidgetLoad={() => setWidgetReady(true)}
320+
onExpire={() => setWidgetReady(false)}
321+
onError={() => setWidgetReady(false)}
322+
onUnsupported={() => setWidgetReady(false)}
323+
/>
324+
) : null}
325+
220326
{submitError ? (
221327
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
222328
{submitError}
223329
</p>
224330
) : null}
225331

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-
>
332+
<button type='submit' disabled={contactMutation.isPending} className={LANDING_SUBMIT}>
235333
{contactMutation.isPending ? 'Sending...' : 'Send message'}
236334
</button>
335+
336+
<p className='text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]'>
337+
By submitting, you agree to our{' '}
338+
<Link
339+
href='/privacy'
340+
className='text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
341+
>
342+
Privacy Policy
343+
</Link>
344+
.
345+
</p>
237346
</form>
238347
)
239348
}

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)