Add SMTP admin settings and contact form delivery

This commit is contained in:
MOH
2026-03-08 06:27:09 +01:00
parent 993a4673d8
commit 5ca4819bcb
28 changed files with 2087 additions and 55 deletions
+109
View File
@@ -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));
}
}
+36 -29
View File
@@ -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>
+22 -10
View File
@@ -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
View File
@@ -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>
);
} }
+1
View File
@@ -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",
+113
View File
@@ -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));
}
}
+110
View File
@@ -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));
}
}
+85
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+11 -3
View File
@@ -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={
+176
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+108
View File
@@ -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({
+112
View File
@@ -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.");
}
}
+132
View File
@@ -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,
},
};
}
+133
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+3
View File
@@ -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"
+3
View File
@@ -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"
+20 -2
View File
@@ -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": {
+2
View File
@@ -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",
+65
View File
@@ -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);
});
});
+88
View File
@@ -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);
});
});
+130
View File
@@ -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.");
});
});
+5 -3
View File
@@ -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",