Add contact email confirmation and abuse protection
This commit is contained in:
+55
-7
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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,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
@@ -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,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 (
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 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:
|
// 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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user