From 0b80c62bebb5b4797de6ae5cad6e1af187e5b97f Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Sat, 16 May 2026 11:38:30 +0200 Subject: [PATCH] Add contact email confirmation and abuse protection --- src/actions/contact.ts | 70 ++++++++++-- src/app/admin/page.tsx | 65 +++++++++++ src/app/contact-form.tsx | 44 +++++++- src/app/login/page.tsx | 3 +- src/app/page.tsx | 3 +- src/app/register/page.tsx | 3 +- src/components/ui/form.tsx | 2 + src/emails/ContactConfirmationEmail.tsx | 141 ++++++++++++++++++++++++ src/lib/contact-schema.ts | 4 + src/lib/post-login-redirect.ts | 14 +++ src/lib/rate-limit.ts | 39 +++++++ src/proxy.ts | 5 + 12 files changed, 376 insertions(+), 17 deletions(-) create mode 100644 src/app/admin/page.tsx create mode 100644 src/emails/ContactConfirmationEmail.tsx create mode 100644 src/lib/post-login-redirect.ts create mode 100644 src/lib/rate-limit.ts diff --git a/src/actions/contact.ts b/src/actions/contact.ts index 49c1757..a8c862b 100644 --- a/src/actions/contact.ts +++ b/src/actions/contact.ts @@ -1,20 +1,52 @@ "use server"; import { createElement } from "react"; +import { headers } from "next/headers"; +import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail"; import { ContactRequestEmail } from "@/emails/ContactRequestEmail"; import { contactRequestSchema, contactRequestTypeLabels, - type ContactRequestInput, + type ContactSubmissionPayload, } from "@/lib/contact-schema"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; +import { rateLimit } from "@/lib/rate-limit"; const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local"; +const RATE_LIMIT_MAX = 5; +const RATE_LIMIT_WINDOW_SECONDS = 60 * 60; + +async function resolveClientIp(): Promise { + const h = await headers(); + const forwarded = h.get("x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0]?.trim() || "unknown"; + } + return h.get("x-real-ip") ?? "unknown"; +} + +export async function submitContactRequest(data: ContactSubmissionPayload) { + // Honeypot: silently absorb bot submissions without informing them. + if (data.website && data.website.trim().length > 0) { + return { success: true }; + } + + const ip = await resolveClientIp(); + const limit = rateLimit( + `contact:${ip}`, + RATE_LIMIT_MAX, + RATE_LIMIT_WINDOW_SECONDS, + ); + if (!limit.allowed) { + const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60)); + return { + success: false, + error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`, + }; + } -export async function submitContactRequest(data: ContactRequestInput) { const parsed = contactRequestSchema.safeParse(data); - if (!parsed.success) { return { success: false, @@ -32,19 +64,35 @@ export async function submitContactRequest(data: ContactRequestInput) { const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL; const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType]; - const result = await sendAppEmail({ - to: adminEmail, - subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`, - replyTo: parsed.data.email, - react: createElement(ContactRequestEmail, parsed.data), - }); - if (!result.success) { + const [adminResult, confirmationResult] = await Promise.all([ + sendAppEmail({ + to: adminEmail, + subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`, + replyTo: parsed.data.email, + react: createElement(ContactRequestEmail, parsed.data), + }), + sendAppEmail({ + to: parsed.data.email, + subject: "Wir haben deine Anfrage erhalten — Kita-Planer", + replyTo: adminEmail, + react: createElement(ContactConfirmationEmail, parsed.data), + }), + ]); + + if (!adminResult.success) { return { success: false, - error: result.error, + error: adminResult.error, }; } + if (!confirmationResult.success) { + console.error( + "Bestätigungs-E-Mail konnte nicht zugestellt werden:", + confirmationResult.error, + ); + } + return { success: true }; } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..53a95b8 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,65 @@ +import type { Metadata } from "next"; +import { LogOut, ShieldCheck } from "lucide-react"; +import { UserRole } from "@prisma/client"; + +import { signOut } from "@/auth"; +import { requireRole } from "@/lib/auth-utils"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export const metadata: Metadata = { + title: "Systemadmin · Kita-Planer", + robots: { + index: false, + follow: false, + }, +}; + +export default async function AdminPage() { + const session = await requireRole([UserRole.SUPERADMIN]); + + return ( +
+ + +
+ +
+
+ Systemadmin-Bereich + + Du bist als Superadmin angemeldet. Eine zentrale Systemverwaltung + ist noch nicht umgesetzt, deshalb endet der Login hier statt auf + einer nicht vorhandenen Seite. + +
+
+ +
+ Angemeldet als{" "} + + {session.user.email ?? session.user.name ?? "Superadmin"} + +
+
{ + "use server"; + await signOut({ redirectTo: "/" }); + }} + > + +
+
+
+
+ ); +} diff --git a/src/app/contact-form.tsx b/src/app/contact-form.tsx index 4ce31dd..68f05fe 100644 --- a/src/app/contact-form.tsx +++ b/src/app/contact-form.tsx @@ -11,6 +11,7 @@ import { contactRequestSchema, contactRequestTypeLabels, type ContactRequestInput, + type ContactSubmissionPayload, } from "@/lib/contact-schema"; import { Button } from "@/components/ui/button"; import { @@ -44,16 +45,27 @@ export function ContactForm() { }, }); - function onSubmit(values: ContactRequestInput) { + function onSubmit( + values: ContactRequestInput, + event?: React.BaseSyntheticEvent, + ) { + const formEl = event?.target as HTMLFormElement | undefined; + const website = + (formEl?.elements.namedItem("website") as HTMLInputElement | null) + ?.value ?? ""; + startTransition(async () => { - const result = await submitContactRequest(values); + const payload: ContactSubmissionPayload = { ...values, website }; + const result = await submitContactRequest(payload); if (!result.success) { toast.error(result.error); return; } - toast.success("Danke! Wir melden uns in Kürze bei euch."); + toast.success( + "Danke! Wir haben dir eine Bestätigung an deine E-Mail-Adresse geschickt.", + ); form.reset({ name: "", kitaName: "", @@ -61,6 +73,10 @@ export function ContactForm() { requestType: "DEMO", message: "", }); + const honeypot = formEl?.elements.namedItem( + "website", + ) as HTMLInputElement | null; + if (honeypot) honeypot.value = ""; }); } @@ -171,6 +187,28 @@ export function ContactForm() { )} /> + +