Add contact email confirmation and abuse protection
This commit is contained in:
+55
-7
@@ -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<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);
|
||||
|
||||
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({
|
||||
|
||||
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 (!result.success) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,7 +18,7 @@ export const metadata = { title: "Anmelden · Kita-Planer" };
|
||||
export default async function LoginPage() {
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
||||
redirect(getPostLoginRedirect(session.user));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -91,7 +92,7 @@ const proofPoints = [
|
||||
export default async function LandingPage() {
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
||||
redirect(getPostLoginRedirect(session.user));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,7 +18,7 @@ export const metadata = { title: "Registrieren · Kita-Planer" };
|
||||
export default async function RegisterPage() {
|
||||
const session = await auth();
|
||||
if (session?.user) {
|
||||
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
|
||||
redirect(getPostLoginRedirect(session.user));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -140,6 +140,8 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
id={formMessageId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className={cn("text-sm text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -19,3 +19,7 @@ export const contactRequestSchema = z.object({
|
||||
});
|
||||
|
||||
export type ContactRequestInput = z.infer<typeof contactRequestSchema>;
|
||||
|
||||
export type ContactSubmissionPayload = ContactRequestInput & {
|
||||
website?: string;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { auth } from "@/auth";
|
||||
// Routing-Logik:
|
||||
// Nicht eingeloggt + geschützte Route → /login
|
||||
// Eingeloggt + kein kitaId + nicht /onboarding → /onboarding
|
||||
// Eingeloggt + SUPERADMIN → /admin
|
||||
// Eingeloggt + hat kitaId + auf /onboarding → /dashboard
|
||||
// Eingeloggt + auf /login oder /register → /
|
||||
// =====================================================================
|
||||
@@ -48,6 +49,10 @@ export async function proxy(request: NextRequest) {
|
||||
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.
|
||||
// Ausnahme: Superadmin hat nie eine kitaId.
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user