Add contact email confirmation and abuse protection

This commit is contained in:
t.indorf
2026-05-16 11:38:30 +02:00
parent 9542def530
commit 0b80c62beb
12 changed files with 376 additions and 17 deletions
+55 -7
View File
@@ -1,20 +1,52 @@
"use server"; "use server";
import { createElement } from "react"; import { createElement } from "react";
import { headers } from "next/headers";
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail"; import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
import { import {
contactRequestSchema, contactRequestSchema,
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactSubmissionPayload,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local"; const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
async function resolveClientIp(): Promise<string> {
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); const parsed = contactRequestSchema.safeParse(data);
if (!parsed.success) { if (!parsed.success) {
return { return {
success: false, success: false,
@@ -32,19 +64,35 @@ export async function submitContactRequest(data: ContactRequestInput) {
const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL; const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL;
const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType]; const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType];
const result = await sendAppEmail({
const [adminResult, confirmationResult] = await Promise.all([
sendAppEmail({
to: adminEmail, to: adminEmail,
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`, subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
replyTo: parsed.data.email, replyTo: parsed.data.email,
react: createElement(ContactRequestEmail, parsed.data), 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 (!result.success) { if (!adminResult.success) {
return { return {
success: false, 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 }; return { success: true };
} }
+65
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-xl">
<CardHeader className="space-y-3">
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<CardTitle>Systemadmin-Bereich</CardTitle>
<CardDescription className="mt-2">
Du bist als Superadmin angemeldet. Eine zentrale Systemverwaltung
ist noch nicht umgesetzt, deshalb endet der Login hier statt auf
einer nicht vorhandenen Seite.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
Angemeldet als{" "}
<span className="font-medium text-foreground">
{session.user.email ?? session.user.name ?? "Superadmin"}
</span>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button type="submit" variant="outline">
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+41 -3
View File
@@ -11,6 +11,7 @@ import {
contactRequestSchema, contactRequestSchema,
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactRequestInput,
type ContactSubmissionPayload,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { 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 () => { startTransition(async () => {
const result = await submitContactRequest(values); const payload: ContactSubmissionPayload = { ...values, website };
const result = await submitContactRequest(payload);
if (!result.success) { if (!result.success) {
toast.error(result.error); toast.error(result.error);
return; 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({ form.reset({
name: "", name: "",
kitaName: "", kitaName: "",
@@ -61,6 +73,10 @@ export function ContactForm() {
requestType: "DEMO", requestType: "DEMO",
message: "", message: "",
}); });
const honeypot = formEl?.elements.namedItem(
"website",
) as HTMLInputElement | null;
if (honeypot) honeypot.value = "";
}); });
} }
@@ -171,6 +187,28 @@ export function ContactForm() {
)} )}
/> />
<div
aria-hidden="true"
style={{
position: "absolute",
left: "-10000px",
top: "auto",
width: "1px",
height: "1px",
overflow: "hidden",
}}
>
<label htmlFor="contact-website">Website (bitte leer lassen)</label>
<input
id="contact-website"
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
defaultValue=""
/>
</div>
<Button type="submit" size="lg" disabled={isPending} className="h-12"> <Button type="submit" size="lg" disabled={isPending} className="h-12">
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
+2 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,7 +18,7 @@ export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() { export default async function LoginPage() {
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
+2 -1
View File
@@ -15,6 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -91,7 +92,7 @@ const proofPoints = [
export default async function LandingPage() { export default async function LandingPage() {
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
+2 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,7 +18,7 @@ export const metadata = { title: "Registrieren · Kita-Planer" };
export default async function RegisterPage() { export default async function RegisterPage() {
const session = await auth(); const session = await auth();
if (session?.user) { if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding"); redirect(getPostLoginRedirect(session.user));
} }
return ( return (
+2
View File
@@ -140,6 +140,8 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
id={formMessageId} id={formMessageId}
role="alert"
aria-live="polite"
className={cn("text-sm text-destructive", className)} className={cn("text-sm text-destructive", className)}
{...props} {...props}
> >
+141
View File
@@ -0,0 +1,141 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import {
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
type ContactConfirmationEmailProps = ContactRequestInput;
export function ContactConfirmationEmail({
name,
kitaName,
requestType,
message,
}: ContactConfirmationEmailProps) {
const inquiryLabel = contactRequestTypeLabels[requestType];
const firstName = name.split(" ")[0] || name;
return (
<Html>
<Head />
<Preview>
Wir haben deine Anfrage bei Kita-Planer erhalten.
</Preview>
<Body style={body}>
<Container style={container}>
<Heading style={heading}>Danke, {firstName}!</Heading>
<Text style={intro}>
Wir haben deine Anfrage erhalten und melden uns in der Regel
innerhalb von ein bis zwei Werktagen mit einem konkreten Vorschlag
bei dir.
</Text>
<Section style={summaryBox}>
<Text style={label}>Zusammenfassung deiner Anfrage</Text>
<InfoRow label="Anliegen" value={inquiryLabel} />
{kitaName ? <InfoRow label="Kita / Initiative" value={kitaName} /> : null}
<Text style={messageLabel}>Deine Nachricht</Text>
<Text style={messageText}>{message}</Text>
</Section>
<Text style={footer}>
Falls du etwas ergänzen möchtest, antworte einfach auf diese
E-Mail. Bis bald das Kita-Planer Team.
</Text>
</Container>
</Body>
</Html>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<Text style={row}>
<strong>{label}:</strong> {value}
</Text>
);
}
const body = {
margin: 0,
backgroundColor: "#f8faf8",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
};
const container = {
margin: "0 auto",
padding: "32px 24px",
maxWidth: "620px",
};
const heading = {
margin: "0 0 12px",
color: "#0f172a",
fontSize: "28px",
lineHeight: "34px",
};
const intro = {
margin: "0 0 24px",
color: "#475569",
fontSize: "15px",
lineHeight: "24px",
};
const summaryBox = {
border: "1px solid #d7e5dc",
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
};
const label = {
margin: "0 0 12px",
color: "#64748b",
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
};
const row = {
margin: "0 0 10px",
color: "#0f172a",
fontSize: "15px",
lineHeight: "22px",
};
const messageLabel = {
margin: "14px 0 8px",
color: "#64748b",
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
};
const messageText = {
margin: 0,
color: "#0f172a",
fontSize: "15px",
lineHeight: "24px",
whiteSpace: "pre-wrap" as const,
};
const footer = {
margin: "22px 0 0",
color: "#64748b",
fontSize: "13px",
lineHeight: "20px",
};
+4
View File
@@ -19,3 +19,7 @@ export const contactRequestSchema = z.object({
}); });
export type ContactRequestInput = z.infer<typeof contactRequestSchema>; export type ContactRequestInput = z.infer<typeof contactRequestSchema>;
export type ContactSubmissionPayload = ContactRequestInput & {
website?: string;
};
+14
View File
@@ -0,0 +1,14 @@
import { UserRole } from "@prisma/client";
type RedirectUser = {
role?: UserRole | string;
kitaId?: string | null;
};
export function getPostLoginRedirect(user: RedirectUser) {
if (user.role === UserRole.SUPERADMIN || user.role === "SUPERADMIN") {
return "/admin";
}
return user.kitaId ? "/dashboard" : "/onboarding";
}
+39
View File
@@ -0,0 +1,39 @@
type Bucket = {
count: number;
resetAt: number;
};
const buckets = new Map<string, Bucket>();
export type RateLimitResult =
| { allowed: true; remaining: number; resetAt: number }
| { allowed: false; retryAfterSeconds: number };
export function rateLimit(
key: string,
limit: number,
windowSeconds: number,
): RateLimitResult {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
}
if (existing.count >= limit) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000),
};
}
existing.count += 1;
return {
allowed: true,
remaining: limit - existing.count,
resetAt: existing.resetAt,
};
}
+5
View File
@@ -12,6 +12,7 @@ import { auth } from "@/auth";
// Routing-Logik: // Routing-Logik:
// Nicht eingeloggt + geschützte Route → /login // Nicht eingeloggt + geschützte Route → /login
// Eingeloggt + kein kitaId + nicht /onboarding → /onboarding // Eingeloggt + kein kitaId + nicht /onboarding → /onboarding
// Eingeloggt + SUPERADMIN → /admin
// Eingeloggt + hat kitaId + auf /onboarding → /dashboard // Eingeloggt + hat kitaId + auf /onboarding → /dashboard
// Eingeloggt + auf /login oder /register → / // Eingeloggt + auf /login oder /register → /
// ===================================================================== // =====================================================================
@@ -48,6 +49,10 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL("/", request.nextUrl)); return NextResponse.redirect(new URL("/", request.nextUrl));
} }
if (user.role === "SUPERADMIN" && pathname === ONBOARDING_ROUTE) {
return NextResponse.redirect(new URL("/admin", request.nextUrl));
}
// 2b. Kein kitaId (heimatloser Admin) → muss Onboarding abschließen. // 2b. Kein kitaId (heimatloser Admin) → muss Onboarding abschließen.
// Ausnahme: Superadmin hat nie eine kitaId. // Ausnahme: Superadmin hat nie eine kitaId.
if ( if (