Add honeypot protection to contact forms
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-23 15:38:34 +01:00
parent d629b745c4
commit 4bf0d5052c
10 changed files with 312 additions and 106 deletions

View File

@@ -25,6 +25,20 @@ interface Props {
packages: Package[];
}
interface HoneypotPayload {
enabled: boolean;
nameFieldName: string;
validFromFieldName: string;
encryptedValidFrom: string;
}
type ContactFormData = {
name: string;
email: string;
message: string;
[key: string]: string;
};
const heroBulletIcons = [Sparkles, ShieldCheck, Camera];
const howStepIcons = [QrCode, Smartphone, ShieldCheck];
@@ -36,13 +50,24 @@ const Home: React.FC<Props> = ({ packages }) => {
variant: heroCtaVariant,
trackClick: trackHeroCtaClick,
} = useCtaExperiment('home_hero_cta');
const { flash } = usePage<{ flash?: { success?: string } }>().props;
const { flash, honeypot } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
const shouldReduceMotion = useReducedMotion();
const { data, setData, post, processing, errors, reset } = useForm({
const honeypotDefaults = React.useMemo(() => {
if (!honeypot?.enabled) {
return {};
}
return {
[honeypot.nameFieldName]: '',
[honeypot.validFromFieldName]: honeypot.encryptedValidFrom,
};
}, [honeypot?.enabled, honeypot?.encryptedValidFrom, honeypot?.nameFieldName, honeypot?.validFromFieldName]);
const { data, setData, post, processing, errors, reset } = useForm<ContactFormData>({
name: '',
email: '',
message: '',
nickname: '',
...honeypotDefaults,
});
const viewportOnce = { once: true, amount: 0.25 };
@@ -619,91 +644,101 @@ const Home: React.FC<Props> = ({ packages }) => {
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<input
type="text"
name="nickname"
value={data.nickname}
onChange={(event) => setData('nickname', event.target.value)}
className="hidden"
tabIndex={-1}
autoComplete="off"
aria-hidden
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.name_label')} *
</label>
<Input
id="name"
value={data.name}
onChange={(event) => setData('name', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="name"
required
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'contact-name-error' : undefined}
/>
{errors.name && (
<p id="contact-name-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.name}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.email_label')} *
</label>
<Input
id="email"
type="email"
value={data.email}
onChange={(event) => setData('email', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="email"
required
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'contact-email-error' : undefined}
/>
{errors.email && (
<p id="contact-email-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.email}</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="message" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.message_label')} *
</label>
<Textarea
id="message"
rows={5}
value={data.message}
onChange={(event) => setData('message', event.target.value)}
className="rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
required
aria-invalid={Boolean(errors.message)}
aria-describedby={errors.message ? 'contact-message-error' : undefined}
/>
{errors.message && (
<p id="contact-message-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.message}</p>
)}
</div>
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('home.contact_privacy')}</p>
<Button
type="submit"
disabled={processing}
className="h-12 w-full rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t('home.sending')}
</span>
) : (
t('home.send')
{honeypot?.enabled ? (
<div className="hidden" aria-hidden>
<input
type="text"
name={honeypot.nameFieldName}
id={honeypot.nameFieldName}
value={data[honeypot.nameFieldName] ?? ''}
onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
autoComplete="off"
tabIndex={-1}
/>
<input
type="text"
name={honeypot.validFromFieldName}
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
readOnly
tabIndex={-1}
/>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.name_label')} *
</label>
<Input
id="name"
value={data.name}
onChange={(event) => setData('name', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="name"
required
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'contact-name-error' : undefined}
/>
{errors.name && (
<p id="contact-name-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.name}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.email_label')} *
</label>
<Input
id="email"
type="email"
value={data.email}
onChange={(event) => setData('email', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="email"
required
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'contact-email-error' : undefined}
/>
{errors.email && (
<p id="contact-email-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.email}</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="message" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.message_label')} *
</label>
<Textarea
id="message"
rows={5}
value={data.message}
onChange={(event) => setData('message', event.target.value)}
className="rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
required
aria-invalid={Boolean(errors.message)}
aria-describedby={errors.message ? 'contact-message-error' : undefined}
/>
{errors.message && (
<p id="contact-message-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.message}</p>
)}
</Button>
</div>
</form>
</div>
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('home.contact_privacy')}</p>
<Button
type="submit"
disabled={processing}
className="h-12 w-full rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t('home.sending')}
</span>
) : (
t('home.send')
)}
</Button>
</div>
</form>
</div>
</CardContent>
</Card>

View File

@@ -6,15 +6,40 @@ import MarketingLayout from '@/layouts/mainWebsite';
import { Loader2, CheckCircle2 } from 'lucide-react';
import { motion, useReducedMotion } from 'framer-motion';
interface HoneypotPayload {
enabled: boolean;
nameFieldName: string;
validFromFieldName: string;
encryptedValidFrom: string;
}
type ContactFormData = {
name: string;
email: string;
message: string;
[key: string]: string;
};
const Kontakt: React.FC = () => {
const { data, setData, post, processing, errors, reset } = useForm({
const { honeypot, flash } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
const honeypotDefaults = React.useMemo(() => {
if (!honeypot?.enabled) {
return {};
}
return {
[honeypot.nameFieldName]: '',
[honeypot.validFromFieldName]: honeypot.encryptedValidFrom,
};
}, [honeypot?.enabled, honeypot?.encryptedValidFrom, honeypot?.nameFieldName, honeypot?.validFromFieldName]);
const { data, setData, post, processing, errors, reset } = useForm<ContactFormData>({
name: '',
email: '',
message: '',
nickname: '',
...honeypotDefaults,
});
const { flash } = usePage<{ flash?: { success?: string } }>().props;
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const shouldReduceMotion = useReducedMotion();
@@ -51,16 +76,26 @@ const Kontakt: React.FC = () => {
</div>
)}
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
name="nickname"
value={data.nickname}
onChange={(e) => setData('nickname', e.target.value)}
className="hidden"
tabIndex={-1}
autoComplete="off"
aria-hidden
/>
{honeypot?.enabled ? (
<div className="hidden" aria-hidden>
<input
type="text"
name={honeypot.nameFieldName}
id={honeypot.nameFieldName}
value={data[honeypot.nameFieldName] ?? ''}
onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
autoComplete="off"
tabIndex={-1}
/>
<input
type="text"
name={honeypot.validFromFieldName}
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
readOnly
tabIndex={-1}
/>
</div>
) : null}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.name')}</label>
<input

View File

@@ -31,7 +31,7 @@ vi.mock('@inertiajs/react', () => ({
Head: () => null,
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useForm: () => ({
data: { name: '', email: '', message: '', nickname: '' },
data: { name: '', email: '', message: '' },
setData: vi.fn(),
post: vi.fn(),
processing: false,

View File

@@ -37,7 +37,7 @@ vi.mock('@inertiajs/react', () => ({
Head: () => null,
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useForm: () => ({
data: { name: '', email: '', message: '', nickname: '' },
data: { name: '', email: '', message: '' },
setData: vi.fn(),
post: vi.fn(),
processing: false,