Add SMTP admin settings and contact form delivery
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { enforceContactRateLimit, verifyTurnstileToken } from "@/lib/contact-guard";
|
||||||
|
import { getContactProtectionSettings } from "@/lib/app-config";
|
||||||
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
|
import { sendContactMessage } from "@/lib/mail";
|
||||||
|
|
||||||
|
const contactFormSchema = z.object({
|
||||||
|
locale: z.string().trim().min(1),
|
||||||
|
name: z.string().trim().min(2).max(120),
|
||||||
|
email: z.string().trim().email(),
|
||||||
|
phone: z.string().trim().max(40).optional(),
|
||||||
|
company: z.string().trim().max(120).optional(),
|
||||||
|
message: z.string().trim().min(10).max(5000),
|
||||||
|
turnstileToken: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactErrorMessages = {
|
||||||
|
ar: {
|
||||||
|
invalid: "يرجى تعبئة كل الحقول بشكل صحيح.",
|
||||||
|
failed: "تعذر إرسال الرسالة حالياً.",
|
||||||
|
blocked: "تم إرسال عدد كبير من الطلبات. حاول لاحقاً.",
|
||||||
|
verification: "يرجى إكمال التحقق قبل الإرسال.",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
invalid: "Please fill all fields correctly.",
|
||||||
|
failed: "Message could not be sent right now.",
|
||||||
|
blocked: "Too many requests. Please try again later.",
|
||||||
|
verification: "Please complete the verification before sending.",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
invalid: "Bitte alle Felder korrekt ausfuellen.",
|
||||||
|
failed: "Nachricht konnte gerade nicht gesendet werden.",
|
||||||
|
blocked: "Zu viele Anfragen. Bitte spaeter erneut versuchen.",
|
||||||
|
verification: "Bitte die Verifizierung vor dem Senden abschliessen.",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function withError(pathname: string, message: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("error", message);
|
||||||
|
|
||||||
|
return `${pathname}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringValue(formData: FormData, key: string) {
|
||||||
|
const value = formData.get(key);
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitContactFormAction(formData: FormData) {
|
||||||
|
const locale = resolveLocale(String(formData.get("locale") ?? ""));
|
||||||
|
const contactPath = getLocalizedPath(locale, "/contact");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const protectionSettings = await getContactProtectionSettings();
|
||||||
|
const values = contactFormSchema.parse({
|
||||||
|
locale,
|
||||||
|
name: getStringValue(formData, "name"),
|
||||||
|
email: getStringValue(formData, "email"),
|
||||||
|
phone: getStringValue(formData, "phone"),
|
||||||
|
company: getStringValue(formData, "company"),
|
||||||
|
message: getStringValue(formData, "message"),
|
||||||
|
turnstileToken: getStringValue(formData, "turnstileToken"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifyTurnstileToken(protectionSettings, values.turnstileToken ?? "");
|
||||||
|
await enforceContactRateLimit(protectionSettings);
|
||||||
|
|
||||||
|
await sendContactMessage({
|
||||||
|
locale,
|
||||||
|
name: values.name,
|
||||||
|
email: values.email,
|
||||||
|
phone: values.phone,
|
||||||
|
company: values.company,
|
||||||
|
message: values.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(getLocalizedPath(locale, "/success"));
|
||||||
|
} catch (error) {
|
||||||
|
if (isRedirectError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
redirect(withError(contactPath, contactErrorMessages[locale].invalid));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === "Too many contact requests. Please try again later.") {
|
||||||
|
redirect(withError(contactPath, contactErrorMessages[locale].blocked));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message === "Turnstile verification is required." ||
|
||||||
|
error.message === "Turnstile verification failed." ||
|
||||||
|
error.message === "Turnstile verification request failed.")
|
||||||
|
) {
|
||||||
|
redirect(withError(contactPath, contactErrorMessages[locale].verification));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Contact form delivery failed.", error);
|
||||||
|
redirect(withError(contactPath, contactErrorMessages[locale].failed));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Mail, MapPin, Phone } from "lucide-react";
|
import { Mail, MapPin, Phone } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { PageHero } from "@/components/layout/page-hero";
|
import { PageHero } from "@/components/layout/page-hero";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { FlashMessage } from "@/components/root/flash-message";
|
||||||
|
import { ContactForm } from "@/components/site/contact-form";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
|
import { getPublicContactProtectionSettings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { submitContactFormAction } from "./actions";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
type ContactPageProps = {
|
type ContactPageProps = {
|
||||||
params: {
|
params: {
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
|
searchParams?: {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
@@ -35,9 +38,15 @@ export async function generateMetadata({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContactPage({ params: { locale } }: ContactPageProps) {
|
export default async function ContactPage({
|
||||||
|
params: { locale },
|
||||||
|
searchParams,
|
||||||
|
}: ContactPageProps) {
|
||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
|
const [t, protection] = await Promise.all([
|
||||||
|
getTranslations({ locale: localeKey, namespace: "contactPage" }),
|
||||||
|
getPublicContactProtectionSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -72,29 +81,27 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
|
|||||||
<MotionFade delay={0.05}>
|
<MotionFade delay={0.05}>
|
||||||
<AppCard>
|
<AppCard>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<form className="grid gap-4">
|
{searchParams?.error ? (
|
||||||
<Label className="grid gap-2">
|
<div className="mb-4">
|
||||||
{t("name")}
|
<FlashMessage type="error" message={searchParams.error} />
|
||||||
<Input type="text" placeholder={t("name")} />
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
{t("email")}
|
|
||||||
<Input type="email" placeholder={t("email")} />
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
{t("message")}
|
|
||||||
<Textarea rows={5} placeholder={t("message")} />
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button type="button">{t("submit")}</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={getLocalizedPath(localeKey, "/success")}>{t("preview")}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
) : null}
|
||||||
|
<ContactForm
|
||||||
|
action={submitContactFormAction}
|
||||||
|
locale={localeKey}
|
||||||
|
previewHref={getLocalizedPath(localeKey, "/success")}
|
||||||
|
protection={protection}
|
||||||
|
copy={{
|
||||||
|
name: t("name"),
|
||||||
|
email: t("email"),
|
||||||
|
phone: t("phone"),
|
||||||
|
company: t("company"),
|
||||||
|
message: t("message"),
|
||||||
|
note: t("note"),
|
||||||
|
submit: t("submit"),
|
||||||
|
preview: t("preview"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { PageHero } from "@/components/layout/page-hero";
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
@@ -38,20 +37,21 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title={t("title")} description={t("text")} />
|
<section className="hero-surface hero-page relative isolate overflow-hidden pb-12 pt-24 sm:pt-28 lg:pb-16 lg:pt-32">
|
||||||
|
<Container size="narrow" className="text-center">
|
||||||
<Container size="narrow" className="pb-12 lg:pb-16">
|
|
||||||
<MotionFade className="w-full">
|
<MotionFade className="w-full">
|
||||||
<AppCard level={3}>
|
<div className="mx-auto flex max-w-[42rem] flex-col items-center">
|
||||||
<CardContent className="p-8 text-center">
|
<CheckCircle2 className="h-14 w-14 text-status-success" />
|
||||||
<CheckCircle2 className="mx-auto h-12 w-12 text-status-success" />
|
<p className="mt-5 text-sm font-medium tracking-[-0.02em] text-foreground/58">
|
||||||
<h1 className="mt-4 text-3xl font-semibold text-foreground sm:text-4xl">
|
{t("title")}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 text-balance text-4xl font-semibold leading-[0.9] tracking-[-0.07em] text-foreground sm:text-5xl lg:text-[4.5rem]">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mt-3 max-w-2xl text-base text-muted-foreground">
|
<p className="mx-auto mt-5 max-w-[32rem] text-sm leading-6 text-foreground/64 sm:text-base">
|
||||||
{t("text")}
|
{t("text")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={getLocalizedPath(localeKey)}>{t("home")}</Link>
|
<Link href={getLocalizedPath(localeKey)}>{t("home")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -59,6 +59,18 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
|
|||||||
<Link href={getLocalizedPath(localeKey, "/contact")}>{t("contact")}</Link>
|
<Link href={getLocalizedPath(localeKey, "/contact")}>{t("contact")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</MotionFade>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Container size="narrow" className="pb-12 lg:pb-16">
|
||||||
|
<MotionFade className="w-full" delay={0.06}>
|
||||||
|
<AppCard level={3}>
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<p className="mx-auto max-w-2xl text-base text-muted-foreground">
|
||||||
|
{t("text")}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|||||||
+5
-1
@@ -21,5 +21,9 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: RootLayoutProps) {
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
return <div className="font-latin">{children}</div>;
|
return (
|
||||||
|
<div dir="ltr" lang="de" className="font-latin">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const copy = {
|
|||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
siteSettings: "SEO",
|
siteSettings: "SEO",
|
||||||
|
smtp: "SMTP",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||||
|
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
getMailSettings,
|
||||||
|
updateMailSettings,
|
||||||
|
} from "@/lib/app-config";
|
||||||
|
import { sendTestEmail } from "@/lib/mail";
|
||||||
|
import type { MailSettings } from "@/lib/mail-settings";
|
||||||
|
|
||||||
|
function ensureAdmin() {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMessage(pathname: string, type: "success" | "error", message: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(type, message);
|
||||||
|
|
||||||
|
return `${pathname}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(value: string) {
|
||||||
|
const port = Number.parseInt(value, 10);
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port <= 0) {
|
||||||
|
throw new Error("SMTP port must be a positive number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMailSettingsFormData(
|
||||||
|
formData: FormData,
|
||||||
|
existingSettings: MailSettings,
|
||||||
|
): MailSettings {
|
||||||
|
const host = String(formData.get("smtpHost") ?? "").trim();
|
||||||
|
const portValue = String(formData.get("smtpPort") ?? "").trim();
|
||||||
|
const username = String(formData.get("smtpUsername") ?? "").trim();
|
||||||
|
const password = String(formData.get("smtpPassword") ?? "");
|
||||||
|
const fromEmail = String(formData.get("mailFromEmail") ?? "").trim();
|
||||||
|
const fromName = String(formData.get("mailFromName") ?? "").trim();
|
||||||
|
const contactRecipient = String(formData.get("mailContactRecipient") ?? "").trim();
|
||||||
|
const testRecipient = String(formData.get("mailTestRecipient") ?? "").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
smtp: {
|
||||||
|
host,
|
||||||
|
port: parsePort(portValue || String(existingSettings.smtp.port)),
|
||||||
|
secure: formData.get("smtpSecure") === "on",
|
||||||
|
username,
|
||||||
|
password: password.trim() ? password : existingSettings.smtp.password,
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: fromEmail,
|
||||||
|
name: fromName,
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact: contactRecipient,
|
||||||
|
test: testRecipient,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMailSettingsAction(formData: FormData) {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingMailSettings = await getMailSettings();
|
||||||
|
const nextMailSettings = parseMailSettingsFormData(formData, existingMailSettings);
|
||||||
|
|
||||||
|
await updateMailSettings(nextMailSettings);
|
||||||
|
revalidatePath("/root/smtp");
|
||||||
|
redirect(withMessage("/root/smtp", "success", "SMTP Einstellungen gespeichert."));
|
||||||
|
} catch (error) {
|
||||||
|
if (isRedirectError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "SMTP Einstellungen konnten nicht gespeichert werden.";
|
||||||
|
|
||||||
|
redirect(withMessage("/root/smtp", "error", message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestEmailAction() {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendTestEmail();
|
||||||
|
redirect(withMessage("/root/smtp", "success", "Test-E-Mail gesendet."));
|
||||||
|
} catch (error) {
|
||||||
|
if (isRedirectError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Test-E-Mail konnte nicht gesendet werden.";
|
||||||
|
|
||||||
|
redirect(withMessage("/root/smtp", "error", message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||||
|
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
getContactProtectionSettings,
|
||||||
|
updateContactProtectionSettings,
|
||||||
|
} from "@/lib/app-config";
|
||||||
|
import type { ContactProtectionSettings } from "@/lib/contact-protection";
|
||||||
|
|
||||||
|
function ensureAdmin() {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMessage(pathname: string, type: "success" | "error", message: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(type, message);
|
||||||
|
|
||||||
|
return `${pathname}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCount(value: string, fallback: number, label: string) {
|
||||||
|
const parsed = Number.parseInt(value || String(fallback), 10);
|
||||||
|
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`${label} must be a positive number.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContactProtectionFormData(
|
||||||
|
formData: FormData,
|
||||||
|
existingSettings: ContactProtectionSettings,
|
||||||
|
): ContactProtectionSettings {
|
||||||
|
const turnstileEnabled = formData.get("turnstileEnabled") === "on";
|
||||||
|
const turnstileSiteKey = String(formData.get("turnstileSiteKey") ?? "").trim();
|
||||||
|
const turnstileSecretKey = String(formData.get("turnstileSecretKey") ?? "");
|
||||||
|
const rateLimitEnabled = formData.get("contactRateLimitEnabled") === "on";
|
||||||
|
|
||||||
|
if (turnstileEnabled && !turnstileSiteKey) {
|
||||||
|
throw new Error("Turnstile site key is required when Turnstile is enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turnstileEnabled && !turnstileSecretKey.trim() && !existingSettings.turnstile.secretKey) {
|
||||||
|
throw new Error("Turnstile secret key is required when Turnstile is enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
turnstile: {
|
||||||
|
enabled: turnstileEnabled,
|
||||||
|
siteKey: turnstileSiteKey,
|
||||||
|
secretKey: turnstileSecretKey.trim()
|
||||||
|
? turnstileSecretKey
|
||||||
|
: existingSettings.turnstile.secretKey,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: rateLimitEnabled,
|
||||||
|
maxRequests: parseCount(
|
||||||
|
String(formData.get("contactRateLimitMaxRequests") ?? ""),
|
||||||
|
existingSettings.rateLimit.maxRequests,
|
||||||
|
"Rate limit max requests",
|
||||||
|
),
|
||||||
|
windowMinutes: parseCount(
|
||||||
|
String(formData.get("contactRateLimitWindowMinutes") ?? ""),
|
||||||
|
existingSettings.rateLimit.windowMinutes,
|
||||||
|
"Rate limit window minutes",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveContactProtectionSettingsAction(formData: FormData) {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingSettings = await getContactProtectionSettings();
|
||||||
|
const nextSettings = parseContactProtectionFormData(formData, existingSettings);
|
||||||
|
|
||||||
|
await updateContactProtectionSettings(nextSettings);
|
||||||
|
revalidatePath("/root/smtp/contact-protection");
|
||||||
|
revalidatePath("/contact");
|
||||||
|
revalidatePath("/ar/contact");
|
||||||
|
revalidatePath("/en/contact");
|
||||||
|
redirect(
|
||||||
|
withMessage(
|
||||||
|
"/root/smtp/contact-protection",
|
||||||
|
"success",
|
||||||
|
"Contact Protection gespeichert.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isRedirectError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Contact Protection konnte nicht gespeichert werden.";
|
||||||
|
|
||||||
|
redirect(withMessage("/root/smtp/contact-protection", "error", message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { ContactProtectionForm } from "@/components/root/contact-protection-form";
|
||||||
|
import { FlashMessage } from "@/components/root/flash-message";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { getContactProtectionFormValues } from "@/lib/app-config";
|
||||||
|
|
||||||
|
import { saveContactProtectionSettingsAction } from "./actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Contact Protection",
|
||||||
|
subtitle: "Turnstile und Rate Limiting fuer das Kontaktformular.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
|
smtp: "SMTP",
|
||||||
|
contactProtection: "Contact Protection",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootSMTPProtectionPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootSMTPProtectionPage({
|
||||||
|
searchParams,
|
||||||
|
}: RootSMTPProtectionPageProps) {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getContactProtectionFormValues();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="smtp"
|
||||||
|
smtpChild="contact-protection"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
saveFormId="contact-protection-form"
|
||||||
|
reloadDocumentOnSave
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<FlashMessage type="success" message={searchParams.success} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<MotionFade delay={0.12}>
|
||||||
|
<FlashMessage type="error" message={searchParams.error} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MotionFade delay={0.16}>
|
||||||
|
<ContactProtectionForm
|
||||||
|
action={saveContactProtectionSettingsAction}
|
||||||
|
initialSettings={settings}
|
||||||
|
/>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { FlashMessage } from "@/components/root/flash-message";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { SMTPSettingsForm } from "@/components/root/smtp-settings-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { getMailSettingsFormValues } from "@/lib/app-config";
|
||||||
|
|
||||||
|
import { saveMailSettingsAction, sendTestEmailAction } from "./actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "SMTP",
|
||||||
|
subtitle: "Mailserver, Absender und Testempfaenger verwalten.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
|
smtp: "SMTP",
|
||||||
|
contactProtection: "Contact Protection",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootSMTPPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootSMTPPage({ searchParams }: RootSMTPPageProps) {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailSettings = await getMailSettingsFormValues();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="smtp"
|
||||||
|
smtpChild="settings"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
saveFormId="smtp-settings-form"
|
||||||
|
reloadDocumentOnSave
|
||||||
|
headerActions={(
|
||||||
|
<form action={sendTestEmailAction}>
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Send test email
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<FlashMessage type="success" message={searchParams.success} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<MotionFade delay={0.12}>
|
||||||
|
<FlashMessage type="error" message={searchParams.error} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MotionFade delay={0.16}>
|
||||||
|
<SMTPSettingsForm
|
||||||
|
action={saveMailSettingsAction}
|
||||||
|
initialSettings={mailSettings}
|
||||||
|
/>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { ContactProtectionFormValues } from "@/lib/contact-protection";
|
||||||
|
|
||||||
|
type ContactProtectionFormProps = {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
initialSettings: ContactProtectionFormValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContactProtectionForm({
|
||||||
|
action,
|
||||||
|
initialSettings,
|
||||||
|
}: ContactProtectionFormProps) {
|
||||||
|
const [turnstileEnabled, setTurnstileEnabled] = useState(initialSettings.turnstile.enabled);
|
||||||
|
const [rateLimitEnabled, setRateLimitEnabled] = useState(initialSettings.rateLimit.enabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="contact-protection-form" action={action} className="space-y-6">
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Turnstile</CardTitle>
|
||||||
|
<CardDescription>Cloudflare Schutz fuer das Kontaktformular.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-4 md:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="turnstileEnabled"
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-nested border border-input bg-card px-4 py-4 transition-colors hover:bg-accent/20"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="turnstileEnabled"
|
||||||
|
name="turnstileEnabled"
|
||||||
|
checked={turnstileEnabled}
|
||||||
|
onCheckedChange={(checked) => setTurnstileEnabled(checked === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-foreground">Enable Turnstile</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{turnstileEnabled
|
||||||
|
? "ON: Besucher muessen die Pruefung bestehen."
|
||||||
|
: "OFF: Kein Turnstile Schutz im Formular."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="turnstileSiteKey">Turnstile Site Key</Label>
|
||||||
|
<Input
|
||||||
|
id="turnstileSiteKey"
|
||||||
|
name="turnstileSiteKey"
|
||||||
|
defaultValue={initialSettings.turnstile.siteKey}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="turnstileSecretKey">Turnstile Secret Key</Label>
|
||||||
|
<Input
|
||||||
|
id="turnstileSecretKey"
|
||||||
|
name="turnstileSecretKey"
|
||||||
|
type="password"
|
||||||
|
defaultValue={initialSettings.turnstile.secretKey}
|
||||||
|
placeholder={initialSettings.turnstile.hasSecretKey ? "Saved secret key will be kept" : "Secret key"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rate Limiting</CardTitle>
|
||||||
|
<CardDescription>Begrenzung wiederholter Kontaktanfragen.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-4 md:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="contactRateLimitEnabled"
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-nested border border-input bg-card px-4 py-4 transition-colors hover:bg-accent/20"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="contactRateLimitEnabled"
|
||||||
|
name="contactRateLimitEnabled"
|
||||||
|
checked={rateLimitEnabled}
|
||||||
|
onCheckedChange={(checked) => setRateLimitEnabled(checked === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-foreground">Enable rate limiting</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{rateLimitEnabled
|
||||||
|
? "ON: Das Formular blockiert zu viele Anfragen pro Zeitfenster."
|
||||||
|
: "OFF: Keine serverseitige Begrenzung aktiv."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contactRateLimitMaxRequests">Max Requests</Label>
|
||||||
|
<Input
|
||||||
|
id="contactRateLimitMaxRequests"
|
||||||
|
name="contactRateLimitMaxRequests"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={initialSettings.rateLimit.maxRequests}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contactRateLimitWindowMinutes">Window Minutes</Label>
|
||||||
|
<Input
|
||||||
|
id="contactRateLimitWindowMinutes"
|
||||||
|
name="contactRateLimitWindowMinutes"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={initialSettings.rateLimit.windowMinutes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { FormSaveButton } from "@/components/root/form-save-button";
|
|||||||
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
|
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
import { getRootNavigation } from "@/lib/root-navigation";
|
import { getRootNavigation } from "@/lib/root-navigation";
|
||||||
@@ -34,13 +35,16 @@ type RootDashboardCopy = {
|
|||||||
portfolio: string;
|
portfolio: string;
|
||||||
media: string;
|
media: string;
|
||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
|
smtp?: string;
|
||||||
|
contactProtection?: string;
|
||||||
logout: string;
|
logout: string;
|
||||||
backToSite: string;
|
backToSite: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RootDashboardShellProps = {
|
type RootDashboardShellProps = {
|
||||||
copy: RootDashboardCopy;
|
copy: RootDashboardCopy;
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings";
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp";
|
||||||
|
smtpChild?: "settings" | "contact-protection";
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
||||||
logoutAction: () => Promise<void>;
|
logoutAction: () => Promise<void>;
|
||||||
headerTitle: string;
|
headerTitle: string;
|
||||||
@@ -58,6 +62,7 @@ type RootDashboardShellProps = {
|
|||||||
export async function RootDashboardShell({
|
export async function RootDashboardShell({
|
||||||
copy,
|
copy,
|
||||||
active,
|
active,
|
||||||
|
smtpChild,
|
||||||
portfolioChild,
|
portfolioChild,
|
||||||
logoutAction,
|
logoutAction,
|
||||||
headerTitle,
|
headerTitle,
|
||||||
@@ -75,7 +80,7 @@ export async function RootDashboardShell({
|
|||||||
getSiteSettingsMediaBindings(),
|
getSiteSettingsMediaBindings(),
|
||||||
getMaintenanceMode(),
|
getMaintenanceMode(),
|
||||||
]);
|
]);
|
||||||
const sidebarItems = getRootNavigation(copy, active, portfolioChild).filter(
|
const normalizedSidebarItems = getRootNavigation(copy, active, smtpChild, portfolioChild).filter(
|
||||||
(item) => item.href !== "/root/maintenance" && item.href !== "/root/ui-kit",
|
(item) => item.href !== "/root/maintenance" && item.href !== "/root/ui-kit",
|
||||||
);
|
);
|
||||||
const headerIcon =
|
const headerIcon =
|
||||||
@@ -87,6 +92,8 @@ export async function RootDashboardShell({
|
|||||||
? SwatchBook
|
? SwatchBook
|
||||||
: active === "site-settings"
|
: active === "site-settings"
|
||||||
? Globe2
|
? Globe2
|
||||||
|
: active === "smtp"
|
||||||
|
? ShieldAlert
|
||||||
: active === "media"
|
: active === "media"
|
||||||
? ImageIcon
|
? ImageIcon
|
||||||
: portfolioChild === "categories"
|
: portfolioChild === "categories"
|
||||||
@@ -111,7 +118,7 @@ export async function RootDashboardShell({
|
|||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
description={headerDescription}
|
description={headerDescription}
|
||||||
icon={headerIcon}
|
icon={headerIcon}
|
||||||
items={sidebarItems}
|
items={normalizedSidebarItems}
|
||||||
sidebarIconSrc={mediaBindings.favicon?.url}
|
sidebarIconSrc={mediaBindings.favicon?.url}
|
||||||
sidebarTop={
|
sidebarTop={
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -149,6 +156,7 @@ export async function RootDashboardShell({
|
|||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<Separator />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
headerActions={
|
headerActions={
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { KeyRound, Mail, Send, Server } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { MailSettingsFormValues } from "@/lib/mail-settings";
|
||||||
|
|
||||||
|
type SMTPSettingsFormProps = {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
initialSettings: MailSettingsFormValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SMTPSettingsForm({
|
||||||
|
action,
|
||||||
|
initialSettings,
|
||||||
|
}: SMTPSettingsFormProps) {
|
||||||
|
const [smtpSecure, setSmtpSecure] = useState(initialSettings.smtp.secure);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="smtp-settings-form" action={action} className="space-y-6">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>SMTP Connection</CardTitle>
|
||||||
|
<CardDescription>Server und Login.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="smtpHost">SMTP Host</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Server className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="smtpHost"
|
||||||
|
name="smtpHost"
|
||||||
|
defaultValue={initialSettings.smtp.host}
|
||||||
|
autoComplete="off"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="smtpPort">SMTP Port</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpPort"
|
||||||
|
name="smtpPort"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={initialSettings.smtp.port}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="smtpUsername">SMTP Username</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="smtpUsername"
|
||||||
|
name="smtpUsername"
|
||||||
|
defaultValue={initialSettings.smtp.username}
|
||||||
|
autoComplete="off"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="smtpPassword">SMTP Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="smtpPassword"
|
||||||
|
name="smtpPassword"
|
||||||
|
type="password"
|
||||||
|
defaultValue={initialSettings.smtp.password}
|
||||||
|
placeholder={initialSettings.smtp.hasPassword ? "Saved password will be kept" : "SMTP password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="smtpSecure"
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-nested border border-input bg-card px-4 py-4 transition-colors hover:bg-accent/20"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="smtpSecure"
|
||||||
|
name="smtpSecure"
|
||||||
|
checked={smtpSecure}
|
||||||
|
onCheckedChange={(checked) => setSmtpSecure(checked === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-foreground">Use secure SMTP</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{smtpSecure ? "ON: meist Port 465." : "OFF: meist Port 587."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sender And Recipients</CardTitle>
|
||||||
|
<CardDescription>Absender und Ziele.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="mailFromEmail">From Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="mailFromEmail"
|
||||||
|
name="mailFromEmail"
|
||||||
|
type="email"
|
||||||
|
defaultValue={initialSettings.sender.email}
|
||||||
|
autoComplete="off"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="mailFromName">From Name</Label>
|
||||||
|
<Input
|
||||||
|
id="mailFromName"
|
||||||
|
name="mailFromName"
|
||||||
|
defaultValue={initialSettings.sender.name}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mailContactRecipient">Contact Recipient Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Send className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="mailContactRecipient"
|
||||||
|
name="mailContactRecipient"
|
||||||
|
type="email"
|
||||||
|
defaultValue={initialSettings.recipients.contact}
|
||||||
|
autoComplete="off"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mailTestRecipient">Test Recipient Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Send className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="mailTestRecipient"
|
||||||
|
name="mailTestRecipient"
|
||||||
|
type="email"
|
||||||
|
defaultValue={initialSettings.recipients.test}
|
||||||
|
autoComplete="off"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ContactTurnstile } from "@/components/site/contact-turnstile";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { PublicContactProtectionSettings } from "@/lib/contact-protection";
|
||||||
|
|
||||||
|
type ContactFormCopy = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
company: string;
|
||||||
|
message: string;
|
||||||
|
note: string;
|
||||||
|
submit: string;
|
||||||
|
preview: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContactFormProps = {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
locale: string;
|
||||||
|
previewHref: string;
|
||||||
|
copy: ContactFormCopy;
|
||||||
|
protection: PublicContactProtectionSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContactForm({
|
||||||
|
action,
|
||||||
|
locale,
|
||||||
|
previewHref,
|
||||||
|
copy,
|
||||||
|
protection,
|
||||||
|
}: ContactFormProps) {
|
||||||
|
return (
|
||||||
|
<form action={action} className="grid gap-5">
|
||||||
|
<input type="hidden" name="locale" value={locale} />
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
{copy.name}
|
||||||
|
<Input type="text" name="name" placeholder={copy.name} required />
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
{copy.email}
|
||||||
|
<Input type="email" name="email" placeholder={copy.email} required />
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
{copy.phone}
|
||||||
|
<Input type="tel" name="phone" placeholder={copy.phone} />
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
{copy.company}
|
||||||
|
<Input type="text" name="company" placeholder={copy.company} />
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
{copy.message}
|
||||||
|
<Textarea rows={7} name="message" placeholder={copy.message} required minLength={10} />
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.note}</p>
|
||||||
|
|
||||||
|
{protection.turnstile.enabled ? (
|
||||||
|
<ContactTurnstile siteKey={protection.turnstile.siteKey} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button type="submit">{copy.submit}</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={previewHref}>{copy.preview}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: {
|
||||||
|
render: (
|
||||||
|
container: HTMLElement,
|
||||||
|
options: {
|
||||||
|
sitekey: string;
|
||||||
|
callback?: (token: string) => void;
|
||||||
|
"expired-callback"?: () => void;
|
||||||
|
"error-callback"?: () => void;
|
||||||
|
},
|
||||||
|
) => string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactTurnstileProps = {
|
||||||
|
siteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContactTurnstile({ siteKey }: ContactTurnstileProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const widgetId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!siteKey || !containerRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
function renderWidget() {
|
||||||
|
if (cancelled || !containerRef.current || !window.turnstile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerRef.current.dataset.widgetMounted === widgetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
window.turnstile.render(containerRef.current, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
callback: (nextToken) => {
|
||||||
|
setToken(nextToken);
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
"expired-callback": () => {
|
||||||
|
setToken("");
|
||||||
|
setError("Verification expired. Please try again.");
|
||||||
|
},
|
||||||
|
"error-callback": () => {
|
||||||
|
setToken("");
|
||||||
|
setError("Verification could not be completed.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
containerRef.current.dataset.widgetMounted = widgetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureScript() {
|
||||||
|
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||||
|
'script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingScript) {
|
||||||
|
if (window.turnstile) {
|
||||||
|
renderWidget();
|
||||||
|
} else {
|
||||||
|
existingScript.addEventListener("load", renderWidget, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.addEventListener("load", renderWidget, { once: true });
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureScript();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [siteKey, widgetId]);
|
||||||
|
|
||||||
|
if (!siteKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div ref={containerRef} />
|
||||||
|
<input type="hidden" name="turnstileToken" value={token} />
|
||||||
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,24 @@ export {
|
|||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
type SiteSettingsMediaBindings,
|
type SiteSettingsMediaBindings,
|
||||||
} from "./site-settings";
|
} from "./site-settings";
|
||||||
|
export {
|
||||||
|
MAIL_SETTINGS_KEY,
|
||||||
|
buildDefaultMailSettings,
|
||||||
|
parseMailSettingsValue,
|
||||||
|
toMailSettingsFormValues,
|
||||||
|
type MailSettings,
|
||||||
|
type MailSettingsFormValues,
|
||||||
|
} from "./mail-settings";
|
||||||
|
export {
|
||||||
|
CONTACT_PROTECTION_SETTINGS_KEY,
|
||||||
|
buildDefaultContactProtectionSettings,
|
||||||
|
parseContactProtectionValue,
|
||||||
|
toContactProtectionFormValues,
|
||||||
|
toPublicContactProtectionSettings,
|
||||||
|
type ContactProtectionSettings,
|
||||||
|
type ContactProtectionFormValues,
|
||||||
|
type PublicContactProtectionSettings,
|
||||||
|
} from "./contact-protection";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SITE_NAME,
|
DEFAULT_SITE_NAME,
|
||||||
SITE_NAME_KEY,
|
SITE_NAME_KEY,
|
||||||
@@ -32,6 +50,24 @@ import {
|
|||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
type SiteSettingsMediaBindings,
|
type SiteSettingsMediaBindings,
|
||||||
} from "./site-settings";
|
} from "./site-settings";
|
||||||
|
import {
|
||||||
|
MAIL_SETTINGS_KEY,
|
||||||
|
buildDefaultMailSettings,
|
||||||
|
parseMailSettingsValue,
|
||||||
|
toMailSettingsFormValues,
|
||||||
|
type MailSettings,
|
||||||
|
type MailSettingsFormValues,
|
||||||
|
} from "./mail-settings";
|
||||||
|
import {
|
||||||
|
CONTACT_PROTECTION_SETTINGS_KEY,
|
||||||
|
buildDefaultContactProtectionSettings,
|
||||||
|
parseContactProtectionValue,
|
||||||
|
toContactProtectionFormValues,
|
||||||
|
toPublicContactProtectionSettings,
|
||||||
|
type ContactProtectionSettings,
|
||||||
|
type ContactProtectionFormValues,
|
||||||
|
type PublicContactProtectionSettings,
|
||||||
|
} from "./contact-protection";
|
||||||
|
|
||||||
export async function getMaintenanceMode(): Promise<boolean> {
|
export async function getMaintenanceMode(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +131,78 @@ export async function updateSiteSettings(settings: SiteSettings): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMailSettings(): Promise<MailSettings> {
|
||||||
|
try {
|
||||||
|
const config = await prisma.appConfig.findUnique({
|
||||||
|
where: { key: MAIL_SETTINGS_KEY },
|
||||||
|
select: { value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseMailSettingsValue(config?.value);
|
||||||
|
} catch {
|
||||||
|
return buildDefaultMailSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMailSettingsFormValues(): Promise<MailSettingsFormValues> {
|
||||||
|
const settings = await getMailSettings();
|
||||||
|
|
||||||
|
return toMailSettingsFormValues(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMailSettings(settings: MailSettings): Promise<void> {
|
||||||
|
await prisma.appConfig.upsert({
|
||||||
|
where: { key: MAIL_SETTINGS_KEY },
|
||||||
|
update: {
|
||||||
|
value: JSON.stringify(settings),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
key: MAIL_SETTINGS_KEY,
|
||||||
|
value: JSON.stringify(settings),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactProtectionSettings(): Promise<ContactProtectionSettings> {
|
||||||
|
try {
|
||||||
|
const config = await prisma.appConfig.findUnique({
|
||||||
|
where: { key: CONTACT_PROTECTION_SETTINGS_KEY },
|
||||||
|
select: { value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseContactProtectionValue(config?.value);
|
||||||
|
} catch {
|
||||||
|
return buildDefaultContactProtectionSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactProtectionFormValues(): Promise<ContactProtectionFormValues> {
|
||||||
|
const settings = await getContactProtectionSettings();
|
||||||
|
|
||||||
|
return toContactProtectionFormValues(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicContactProtectionSettings(): Promise<PublicContactProtectionSettings> {
|
||||||
|
const settings = await getContactProtectionSettings();
|
||||||
|
|
||||||
|
return toPublicContactProtectionSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContactProtectionSettings(
|
||||||
|
settings: ContactProtectionSettings,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.appConfig.upsert({
|
||||||
|
where: { key: CONTACT_PROTECTION_SETTINGS_KEY },
|
||||||
|
update: {
|
||||||
|
value: JSON.stringify(settings),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
key: CONTACT_PROTECTION_SETTINGS_KEY,
|
||||||
|
value: JSON.stringify(settings),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
||||||
try {
|
try {
|
||||||
const usages = await prisma.mediaUsage.findMany({
|
const usages = await prisma.mediaUsage.findMany({
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
CONTACT_RATE_LIMIT_KEY_PREFIX,
|
||||||
|
type ContactProtectionSettings,
|
||||||
|
} from "@/lib/contact-protection";
|
||||||
|
|
||||||
|
function getClientIpFromHeaders() {
|
||||||
|
const requestHeaders = headers();
|
||||||
|
const forwardedFor = requestHeaders.get("x-forwarded-for");
|
||||||
|
|
||||||
|
if (forwardedFor) {
|
||||||
|
return forwardedFor.split(",")[0]?.trim() || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestHeaders.get("x-real-ip")?.trim() || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRateLimitKey(ip: string, windowMinutes: number) {
|
||||||
|
const windowMs = windowMinutes * 60 * 1000;
|
||||||
|
const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
|
||||||
|
const ipHash = createHash("sha256").update(ip).digest("hex");
|
||||||
|
|
||||||
|
return `${CONTACT_RATE_LIMIT_KEY_PREFIX}:${ipHash}:${windowStart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCount(rawValue: string | null | undefined) {
|
||||||
|
if (!rawValue) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(rawValue, 10);
|
||||||
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enforceContactRateLimit(settings: ContactProtectionSettings) {
|
||||||
|
if (!settings.rateLimit.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getClientIpFromHeaders();
|
||||||
|
const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.appConfig.findUnique({
|
||||||
|
where: { key },
|
||||||
|
select: { value: true },
|
||||||
|
});
|
||||||
|
const nextCount = parseCount(current?.value) + 1;
|
||||||
|
|
||||||
|
if (nextCount > settings.rateLimit.maxRequests) {
|
||||||
|
throw new Error("Too many contact requests. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.appConfig.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: {
|
||||||
|
value: String(nextCount),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
key,
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTurnstileToken(
|
||||||
|
settings: ContactProtectionSettings,
|
||||||
|
token: string,
|
||||||
|
) {
|
||||||
|
if (!settings.turnstile.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.turnstile.siteKey || !settings.turnstile.secretKey) {
|
||||||
|
throw new Error("Turnstile is enabled but not fully configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.trim()) {
|
||||||
|
throw new Error("Turnstile verification is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("secret", settings.turnstile.secretKey);
|
||||||
|
body.set("response", token);
|
||||||
|
body.set("remoteip", getClientIpFromHeaders());
|
||||||
|
|
||||||
|
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Turnstile verification request failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as {
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error("Turnstile verification failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
export const CONTACT_PROTECTION_SETTINGS_KEY = "contact_protection_settings";
|
||||||
|
export const CONTACT_RATE_LIMIT_KEY_PREFIX = "contact_rate_limit";
|
||||||
|
|
||||||
|
export type ContactProtectionSettings = {
|
||||||
|
turnstile: {
|
||||||
|
enabled: boolean;
|
||||||
|
siteKey: string;
|
||||||
|
secretKey: string;
|
||||||
|
};
|
||||||
|
rateLimit: {
|
||||||
|
enabled: boolean;
|
||||||
|
maxRequests: number;
|
||||||
|
windowMinutes: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContactProtectionFormValues = {
|
||||||
|
turnstile: {
|
||||||
|
enabled: boolean;
|
||||||
|
siteKey: string;
|
||||||
|
secretKey: string;
|
||||||
|
hasSecretKey: boolean;
|
||||||
|
};
|
||||||
|
rateLimit: {
|
||||||
|
enabled: boolean;
|
||||||
|
maxRequests: number;
|
||||||
|
windowMinutes: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicContactProtectionSettings = {
|
||||||
|
turnstile: {
|
||||||
|
enabled: boolean;
|
||||||
|
siteKey: string;
|
||||||
|
};
|
||||||
|
rateLimit: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDefaultContactProtectionSettings(): ContactProtectionSettings {
|
||||||
|
return {
|
||||||
|
turnstile: {
|
||||||
|
enabled: false,
|
||||||
|
siteKey: "",
|
||||||
|
secretKey: "",
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: 5,
|
||||||
|
windowMinutes: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: unknown, fallback: number) {
|
||||||
|
const parsed =
|
||||||
|
typeof value === "number"
|
||||||
|
? value
|
||||||
|
: typeof value === "string"
|
||||||
|
? Number.parseInt(value, 10)
|
||||||
|
: fallback;
|
||||||
|
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContactProtectionValue(
|
||||||
|
rawValue: string | null | undefined,
|
||||||
|
): ContactProtectionSettings {
|
||||||
|
const defaults = buildDefaultContactProtectionSettings();
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
|
||||||
|
const turnstile = parsed.turnstile && typeof parsed.turnstile === "object"
|
||||||
|
? (parsed.turnstile as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const rateLimit = parsed.rateLimit && typeof parsed.rateLimit === "object"
|
||||||
|
? (parsed.rateLimit as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
turnstile: {
|
||||||
|
enabled: Boolean(turnstile.enabled),
|
||||||
|
siteKey: typeof turnstile.siteKey === "string" ? turnstile.siteKey.trim() : "",
|
||||||
|
secretKey: typeof turnstile.secretKey === "string" ? turnstile.secretKey : "",
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: rateLimit.enabled === undefined ? defaults.rateLimit.enabled : Boolean(rateLimit.enabled),
|
||||||
|
maxRequests: parsePositiveInt(rateLimit.maxRequests, defaults.rateLimit.maxRequests),
|
||||||
|
windowMinutes: parsePositiveInt(rateLimit.windowMinutes, defaults.rateLimit.windowMinutes),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toContactProtectionFormValues(
|
||||||
|
settings: ContactProtectionSettings,
|
||||||
|
): ContactProtectionFormValues {
|
||||||
|
return {
|
||||||
|
turnstile: {
|
||||||
|
enabled: settings.turnstile.enabled,
|
||||||
|
siteKey: settings.turnstile.siteKey,
|
||||||
|
secretKey: "",
|
||||||
|
hasSecretKey: Boolean(settings.turnstile.secretKey),
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: settings.rateLimit.enabled,
|
||||||
|
maxRequests: settings.rateLimit.maxRequests,
|
||||||
|
windowMinutes: settings.rateLimit.windowMinutes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPublicContactProtectionSettings(
|
||||||
|
settings: ContactProtectionSettings,
|
||||||
|
): PublicContactProtectionSettings {
|
||||||
|
return {
|
||||||
|
turnstile: {
|
||||||
|
enabled: settings.turnstile.enabled && Boolean(settings.turnstile.siteKey),
|
||||||
|
siteKey: settings.turnstile.siteKey,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: settings.rateLimit.enabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
export const MAIL_SETTINGS_KEY = "mail_settings";
|
||||||
|
|
||||||
|
export type MailSettings = {
|
||||||
|
smtp: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
sender: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
recipients: {
|
||||||
|
contact: string;
|
||||||
|
test: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailSettingsFormValues = {
|
||||||
|
smtp: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
};
|
||||||
|
sender: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
recipients: {
|
||||||
|
contact: string;
|
||||||
|
test: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDefaultMailSettings(): MailSettings {
|
||||||
|
return {
|
||||||
|
smtp: {
|
||||||
|
host: "",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact: "",
|
||||||
|
test: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMailSettings(input: unknown): MailSettings {
|
||||||
|
const value = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
|
||||||
|
const smtp = value.smtp && typeof value.smtp === "object"
|
||||||
|
? (value.smtp as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const sender = value.sender && typeof value.sender === "object"
|
||||||
|
? (value.sender as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const recipients = value.recipients && typeof value.recipients === "object"
|
||||||
|
? (value.recipients as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const defaults = buildDefaultMailSettings();
|
||||||
|
const parsedPort =
|
||||||
|
typeof smtp.port === "number"
|
||||||
|
? smtp.port
|
||||||
|
: typeof smtp.port === "string"
|
||||||
|
? Number.parseInt(smtp.port, 10)
|
||||||
|
: defaults.smtp.port;
|
||||||
|
|
||||||
|
return {
|
||||||
|
smtp: {
|
||||||
|
host: typeof smtp.host === "string" ? smtp.host.trim() : defaults.smtp.host,
|
||||||
|
port: Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : defaults.smtp.port,
|
||||||
|
secure: Boolean(smtp.secure),
|
||||||
|
username: typeof smtp.username === "string" ? smtp.username.trim() : defaults.smtp.username,
|
||||||
|
password: typeof smtp.password === "string" ? smtp.password : defaults.smtp.password,
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: typeof sender.email === "string" ? sender.email.trim() : defaults.sender.email,
|
||||||
|
name: typeof sender.name === "string" ? sender.name.trim() : defaults.sender.name,
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact:
|
||||||
|
typeof recipients.contact === "string"
|
||||||
|
? recipients.contact.trim()
|
||||||
|
: defaults.recipients.contact,
|
||||||
|
test:
|
||||||
|
typeof recipients.test === "string" ? recipients.test.trim() : defaults.recipients.test,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMailSettingsValue(rawValue: string | null | undefined): MailSettings {
|
||||||
|
if (!rawValue) {
|
||||||
|
return buildDefaultMailSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizeMailSettings(JSON.parse(rawValue));
|
||||||
|
} catch {
|
||||||
|
return buildDefaultMailSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMailSettingsFormValues(settings: MailSettings): MailSettingsFormValues {
|
||||||
|
return {
|
||||||
|
smtp: {
|
||||||
|
host: settings.smtp.host,
|
||||||
|
port: settings.smtp.port,
|
||||||
|
secure: settings.smtp.secure,
|
||||||
|
username: settings.smtp.username,
|
||||||
|
password: "",
|
||||||
|
hasPassword: Boolean(settings.smtp.password),
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: settings.sender.email,
|
||||||
|
name: settings.sender.name,
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact: settings.recipients.contact,
|
||||||
|
test: settings.recipients.test,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
import nodemailer, { type SendMailOptions } from "nodemailer";
|
||||||
|
|
||||||
|
import { getMailSettings } from "@/lib/app-config";
|
||||||
|
import type { AppLocale } from "@/lib/locale";
|
||||||
|
import type { MailSettings } from "@/lib/mail-settings";
|
||||||
|
|
||||||
|
type MailTransportOptions = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
auth: {
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MailTransport = {
|
||||||
|
sendMail: (options: SendMailOptions) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateTransport = (options: MailTransportOptions) => MailTransport;
|
||||||
|
|
||||||
|
type SendMailDeps = {
|
||||||
|
settings?: MailSettings;
|
||||||
|
createTransport?: CreateTransport;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SendMailInput = {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
replyTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContactMessageInput = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
company?: string;
|
||||||
|
message: string;
|
||||||
|
locale: AppLocale;
|
||||||
|
};
|
||||||
|
|
||||||
|
function requireValue(value: string, message: string) {
|
||||||
|
if (!value.trim()) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredTransportSettings(settings: MailSettings) {
|
||||||
|
const smtpHost = requireValue(settings.smtp.host, "SMTP host is required.");
|
||||||
|
const smtpUsername = requireValue(settings.smtp.username, "SMTP username is required.");
|
||||||
|
const smtpPassword = requireValue(settings.smtp.password, "SMTP password is required.");
|
||||||
|
const fromEmail = requireValue(settings.sender.email, "From email is required.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
smtpHost,
|
||||||
|
smtpUsername,
|
||||||
|
smtpPassword,
|
||||||
|
fromEmail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredRecipient(settings: MailSettings, recipientKey: "contact" | "test") {
|
||||||
|
if (recipientKey === "contact") {
|
||||||
|
return requireValue(
|
||||||
|
settings.recipients.contact || settings.recipients.test,
|
||||||
|
"Contact recipient email is required.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return requireValue(
|
||||||
|
settings.recipients.test || settings.recipients.contact,
|
||||||
|
"Test recipient email is required.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSmtpTransport(
|
||||||
|
settings: MailSettings,
|
||||||
|
createTransport: CreateTransport = nodemailer.createTransport,
|
||||||
|
) {
|
||||||
|
const { smtpHost, smtpUsername, smtpPassword } = getConfiguredTransportSettings(settings);
|
||||||
|
|
||||||
|
return createTransport({
|
||||||
|
host: smtpHost,
|
||||||
|
port: settings.smtp.port,
|
||||||
|
secure: settings.smtp.secure,
|
||||||
|
auth: {
|
||||||
|
user: smtpUsername,
|
||||||
|
pass: smtpPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMail(
|
||||||
|
input: SendMailInput,
|
||||||
|
deps: SendMailDeps = {},
|
||||||
|
) {
|
||||||
|
const settings = deps.settings ?? await getMailSettings();
|
||||||
|
const { fromEmail } = getConfiguredTransportSettings(settings);
|
||||||
|
const transport = createSmtpTransport(settings, deps.createTransport);
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: settings.sender.name ? `${settings.sender.name} <${fromEmail}>` : fromEmail,
|
||||||
|
to: input.to,
|
||||||
|
subject: input.subject,
|
||||||
|
text: input.text,
|
||||||
|
replyTo: input.replyTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendContactMessage(
|
||||||
|
input: ContactMessageInput,
|
||||||
|
deps: SendMailDeps = {},
|
||||||
|
) {
|
||||||
|
const settings = deps.settings ?? await getMailSettings();
|
||||||
|
const recipient = getConfiguredRecipient(settings, "contact");
|
||||||
|
|
||||||
|
await sendMail(
|
||||||
|
{
|
||||||
|
to: recipient,
|
||||||
|
subject: "New contact message",
|
||||||
|
replyTo: input.email,
|
||||||
|
text: [
|
||||||
|
"A new contact message was submitted.",
|
||||||
|
"",
|
||||||
|
`Name: ${input.name}`,
|
||||||
|
`Email: ${input.email}`,
|
||||||
|
`Phone: ${input.phone?.trim() || "-"}`,
|
||||||
|
`Company: ${input.company?.trim() || "-"}`,
|
||||||
|
`Locale: ${input.locale}`,
|
||||||
|
`Submitted at: ${new Date().toISOString()}`,
|
||||||
|
"",
|
||||||
|
"Message:",
|
||||||
|
input.message,
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings,
|
||||||
|
createTransport: deps.createTransport,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestEmail(
|
||||||
|
deps: SendMailDeps = {},
|
||||||
|
) {
|
||||||
|
const settings = deps.settings ?? await getMailSettings();
|
||||||
|
const recipient = getConfiguredRecipient(settings, "test");
|
||||||
|
const { fromEmail } = getConfiguredTransportSettings(settings);
|
||||||
|
|
||||||
|
await sendMail(
|
||||||
|
{
|
||||||
|
to: recipient,
|
||||||
|
subject: "SMTP test email",
|
||||||
|
text: [
|
||||||
|
"This is a backend SMTP test email.",
|
||||||
|
"",
|
||||||
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
|
`Configured from email: ${fromEmail}`,
|
||||||
|
`Configured recipient: ${recipient}`,
|
||||||
|
`Environment: ${process.env.NODE_ENV ?? "development"}`,
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings,
|
||||||
|
createTransport: deps.createTransport,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+26
-1
@@ -3,6 +3,7 @@ import {
|
|||||||
Globe2,
|
Globe2,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Mail,
|
||||||
PlusSquare,
|
PlusSquare,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
@@ -17,6 +18,8 @@ type RootNavigationCopy = {
|
|||||||
portfolio: string;
|
portfolio: string;
|
||||||
media: string;
|
media: string;
|
||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
|
smtp?: string;
|
||||||
|
contactProtection?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootNavItem = {
|
export type RootNavItem = {
|
||||||
@@ -30,7 +33,8 @@ export type RootNavItem = {
|
|||||||
|
|
||||||
export function getRootNavigation(
|
export function getRootNavigation(
|
||||||
copy: RootNavigationCopy,
|
copy: RootNavigationCopy,
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings",
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp",
|
||||||
|
smtpChild?: "settings" | "contact-protection",
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||||
): RootNavItem[] {
|
): RootNavItem[] {
|
||||||
return [
|
return [
|
||||||
@@ -64,6 +68,27 @@ export function getRootNavigation(
|
|||||||
icon: Globe2,
|
icon: Globe2,
|
||||||
active: active === "site-settings",
|
active: active === "site-settings",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: copy.smtp ?? "SMTP",
|
||||||
|
href: "/root/smtp",
|
||||||
|
icon: Mail,
|
||||||
|
active: active === "smtp" && !smtpChild,
|
||||||
|
expanded: active === "smtp",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: copy.smtp ?? "SMTP",
|
||||||
|
href: "/root/smtp",
|
||||||
|
icon: Mail,
|
||||||
|
active: smtpChild === "settings" || (!smtpChild && active === "smtp"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: copy.contactProtection ?? "Contact Protection",
|
||||||
|
href: "/root/smtp/contact-protection",
|
||||||
|
icon: ShieldAlert,
|
||||||
|
active: smtpChild === "contact-protection",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: copy.portfolio,
|
label: copy.portfolio,
|
||||||
href: "/root/portfolio",
|
href: "/root/portfolio",
|
||||||
|
|||||||
@@ -76,7 +76,10 @@
|
|||||||
"intro": "Schreib mir kurz dein Ziel und ich melde mich zeitnah.",
|
"intro": "Schreib mir kurz dein Ziel und ich melde mich zeitnah.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"company": "Firma",
|
||||||
"message": "Nachricht",
|
"message": "Nachricht",
|
||||||
|
"note": "Teile kurz Kontext, Ziel und Deadline mit, damit die Antwort praeziser ausfallen kann.",
|
||||||
"submit": "Senden",
|
"submit": "Senden",
|
||||||
"preview": "Success Seite ansehen",
|
"preview": "Success Seite ansehen",
|
||||||
"city": "Berlin, Deutschland"
|
"city": "Berlin, Deutschland"
|
||||||
|
|||||||
@@ -76,7 +76,10 @@
|
|||||||
"intro": "Share your goal and I will get back quickly.",
|
"intro": "Share your goal and I will get back quickly.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"company": "Company",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
|
"note": "Share the context, goals, and any deadline so the reply can be more precise.",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"preview": "Open success page",
|
"preview": "Open success page",
|
||||||
"city": "Berlin, Germany"
|
"city": "Berlin, Germany"
|
||||||
|
|||||||
Generated
+20
-2
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
@@ -3084,12 +3086,20 @@
|
|||||||
"version": "20.19.37",
|
"version": "20.19.37",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
|
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
|
||||||
@@ -7495,6 +7505,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -9755,7 +9774,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDefaultContactProtectionSettings,
|
||||||
|
parseContactProtectionValue,
|
||||||
|
toContactProtectionFormValues,
|
||||||
|
} from "../lib/contact-protection";
|
||||||
|
|
||||||
|
describe("contact protection helpers", () => {
|
||||||
|
it("builds safe defaults", () => {
|
||||||
|
expect(buildDefaultContactProtectionSettings()).toEqual({
|
||||||
|
turnstile: {
|
||||||
|
enabled: false,
|
||||||
|
siteKey: "",
|
||||||
|
secretKey: "",
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: 5,
|
||||||
|
windowMinutes: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses stored settings and trims keys", () => {
|
||||||
|
const settings = parseContactProtectionValue(
|
||||||
|
JSON.stringify({
|
||||||
|
turnstile: {
|
||||||
|
enabled: true,
|
||||||
|
siteKey: " 0x4AAAAA ",
|
||||||
|
secretKey: "secret",
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: "8",
|
||||||
|
windowMinutes: "15",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settings.turnstile.enabled).toBe(true);
|
||||||
|
expect(settings.turnstile.siteKey).toBe("0x4AAAAA");
|
||||||
|
expect(settings.turnstile.secretKey).toBe("secret");
|
||||||
|
expect(settings.rateLimit.maxRequests).toBe(8);
|
||||||
|
expect(settings.rateLimit.windowMinutes).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the stored secret in form values", () => {
|
||||||
|
const values = toContactProtectionFormValues({
|
||||||
|
turnstile: {
|
||||||
|
enabled: true,
|
||||||
|
siteKey: "site-key",
|
||||||
|
secretKey: "secret-key",
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: 5,
|
||||||
|
windowMinutes: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.turnstile.secretKey).toBe("");
|
||||||
|
expect(values.turnstile.hasSecretKey).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDefaultMailSettings,
|
||||||
|
parseMailSettingsValue,
|
||||||
|
toMailSettingsFormValues,
|
||||||
|
} from "../lib/mail-settings";
|
||||||
|
|
||||||
|
describe("mail settings helpers", () => {
|
||||||
|
it("builds safe defaults", () => {
|
||||||
|
expect(buildDefaultMailSettings()).toEqual({
|
||||||
|
smtp: {
|
||||||
|
host: "",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact: "",
|
||||||
|
test: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses stored values and trims strings", () => {
|
||||||
|
const settings = parseMailSettingsValue(
|
||||||
|
JSON.stringify({
|
||||||
|
smtp: {
|
||||||
|
host: " smtp.example.com ",
|
||||||
|
port: "465",
|
||||||
|
secure: true,
|
||||||
|
username: " mailer ",
|
||||||
|
password: "secret",
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: " hello@example.com ",
|
||||||
|
name: " Studio Moh ",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact: " inbox@example.com ",
|
||||||
|
test: " test@example.com ",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settings.smtp.host).toBe("smtp.example.com");
|
||||||
|
expect(settings.smtp.port).toBe(465);
|
||||||
|
expect(settings.smtp.secure).toBe(true);
|
||||||
|
expect(settings.smtp.username).toBe("mailer");
|
||||||
|
expect(settings.smtp.password).toBe("secret");
|
||||||
|
expect(settings.sender.email).toBe("hello@example.com");
|
||||||
|
expect(settings.sender.name).toBe("Studio Moh");
|
||||||
|
expect(settings.recipients.contact).toBe("inbox@example.com");
|
||||||
|
expect(settings.recipients.test).toBe("test@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when json is invalid", () => {
|
||||||
|
expect(parseMailSettingsValue("{invalid-json")).toEqual(buildDefaultMailSettings());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the saved password in form values", () => {
|
||||||
|
const formValues = toMailSettingsFormValues({
|
||||||
|
smtp: {
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
username: "mailer",
|
||||||
|
password: "secret",
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: "hello@example.com",
|
||||||
|
name: "Studio Moh",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact: "inbox@example.com",
|
||||||
|
test: "test@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formValues.smtp.password).toBe("");
|
||||||
|
expect(formValues.smtp.hasPassword).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { MailSettings } from "../lib/mail-settings";
|
||||||
|
import { sendContactMessage, sendTestEmail } from "../lib/mail";
|
||||||
|
|
||||||
|
function createMailSettings(overrides: Partial<MailSettings> = {}): MailSettings {
|
||||||
|
return {
|
||||||
|
smtp: {
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
username: "mailer@example.com",
|
||||||
|
password: "secret",
|
||||||
|
...overrides.smtp,
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
email: "hello@example.com",
|
||||||
|
name: "Studio Moh",
|
||||||
|
...overrides.sender,
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
contact: "contact@example.com",
|
||||||
|
test: "test@example.com",
|
||||||
|
...overrides.recipients,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mail delivery", () => {
|
||||||
|
it("sends contact messages with reply-to", async () => {
|
||||||
|
const sendMailMock = vi.fn().mockResolvedValue({});
|
||||||
|
const createTransport = vi.fn().mockReturnValue({
|
||||||
|
sendMail: sendMailMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendContactMessage(
|
||||||
|
{
|
||||||
|
locale: "en",
|
||||||
|
name: "Jane Doe",
|
||||||
|
email: "jane@example.com",
|
||||||
|
message: "Hello from the website contact form.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: createMailSettings(),
|
||||||
|
createTransport,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createTransport).toHaveBeenCalledWith({
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: "mailer@example.com",
|
||||||
|
pass: "secret",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(sendMailMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "contact@example.com",
|
||||||
|
subject: "New contact message",
|
||||||
|
replyTo: "jane@example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendMailMock.mock.calls[0]?.[0].text).toContain("Hello from the website contact form.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the test recipient when contact recipient is empty", async () => {
|
||||||
|
const sendMailMock = vi.fn().mockResolvedValue({});
|
||||||
|
const createTransport = vi.fn().mockReturnValue({
|
||||||
|
sendMail: sendMailMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendContactMessage(
|
||||||
|
{
|
||||||
|
locale: "de",
|
||||||
|
name: "Jane Doe",
|
||||||
|
email: "jane@example.com",
|
||||||
|
message: "Fallback recipient should still work.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: createMailSettings({
|
||||||
|
recipients: {
|
||||||
|
contact: "",
|
||||||
|
test: "fallback@example.com",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createTransport,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendMailMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "fallback@example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends backend test emails to the configured recipient", async () => {
|
||||||
|
const sendMailMock = vi.fn().mockResolvedValue({});
|
||||||
|
const createTransport = vi.fn().mockReturnValue({
|
||||||
|
sendMail: sendMailMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendTestEmail({
|
||||||
|
settings: createMailSettings(),
|
||||||
|
createTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMailMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@example.com",
|
||||||
|
subject: "SMTP test email",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the transport rejects the test email", async () => {
|
||||||
|
const createTransport = vi.fn().mockReturnValue({
|
||||||
|
sendMail: vi.fn().mockRejectedValue(new Error("Authentication failed.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendTestEmail({
|
||||||
|
settings: createMailSettings(),
|
||||||
|
createTransport,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Authentication failed.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,18 +23,20 @@ describe("metadata helpers", () => {
|
|||||||
favicon: {
|
favicon: {
|
||||||
assetId: "fav",
|
assetId: "fav",
|
||||||
url: "/uploads/media/site-settings/favicon.svg",
|
url: "/uploads/media/site-settings/favicon.svg",
|
||||||
|
version: "v1",
|
||||||
},
|
},
|
||||||
defaultOgImage: {
|
defaultOgImage: {
|
||||||
assetId: "og",
|
assetId: "og",
|
||||||
url: "/uploads/media/site-settings/default-og.png",
|
url: "/uploads/media/site-settings/default-og.png",
|
||||||
|
version: "v1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(metadata.title).toBe("Studio Moh");
|
expect(metadata.title).toBe("Studio Moh");
|
||||||
expect(metadata.icons).toEqual({
|
expect(metadata.icons).toEqual({
|
||||||
icon: [{ url: "/favicon.ico?v=default" }],
|
icon: [{ url: "/favicon.ico?v=v1" }],
|
||||||
shortcut: [{ url: "/favicon.ico?v=default" }],
|
shortcut: [{ url: "/favicon.ico?v=v1" }],
|
||||||
apple: [{ url: "/apple-icon.png?v=default" }],
|
apple: [{ url: "/apple-icon.png?v=v1" }],
|
||||||
});
|
});
|
||||||
expect(metadata.twitter).toMatchObject({
|
expect(metadata.twitter).toMatchObject({
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
|
|||||||
Reference in New Issue
Block a user