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 { Mail, MapPin, Phone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { PageHero } from "@/components/layout/page-hero";
|
||||
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 { getPublicContactProtectionSettings } from "@/lib/app-config";
|
||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { submitContactFormAction } from "./actions";
|
||||
|
||||
type ContactPageProps = {
|
||||
params: {
|
||||
locale: string;
|
||||
};
|
||||
searchParams?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
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 t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
|
||||
const [t, protection] = await Promise.all([
|
||||
getTranslations({ locale: localeKey, namespace: "contactPage" }),
|
||||
getPublicContactProtectionSettings(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -72,29 +81,27 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
|
||||
<MotionFade delay={0.05}>
|
||||
<AppCard>
|
||||
<CardContent className="pt-6">
|
||||
<form className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
{t("name")}
|
||||
<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>
|
||||
{searchParams?.error ? (
|
||||
<div className="mb-4">
|
||||
<FlashMessage type="error" message={searchParams.error} />
|
||||
</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>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
@@ -4,7 +4,6 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { PageHero } from "@/components/layout/page-hero";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||
@@ -38,27 +37,40 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
|
||||
|
||||
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">
|
||||
<MotionFade className="w-full">
|
||||
<div className="mx-auto flex max-w-[42rem] flex-col items-center">
|
||||
<CheckCircle2 className="h-14 w-14 text-status-success" />
|
||||
<p className="mt-5 text-sm font-medium tracking-[-0.02em] text-foreground/58">
|
||||
{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")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-[32rem] text-sm leading-6 text-foreground/64 sm:text-base">
|
||||
{t("text")}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button asChild>
|
||||
<Link href={getLocalizedPath(localeKey)}>{t("home")}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={getLocalizedPath(localeKey, "/contact")}>{t("contact")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</MotionFade>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Container size="narrow" className="pb-12 lg:pb-16">
|
||||
<MotionFade className="w-full">
|
||||
<MotionFade className="w-full" delay={0.06}>
|
||||
<AppCard level={3}>
|
||||
<CardContent className="p-8 text-center">
|
||||
<CheckCircle2 className="mx-auto h-12 w-12 text-status-success" />
|
||||
<h1 className="mt-4 text-3xl font-semibold text-foreground sm:text-4xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-2xl text-base text-muted-foreground">
|
||||
<p className="mx-auto max-w-2xl text-base text-muted-foreground">
|
||||
{t("text")}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button asChild>
|
||||
<Link href={getLocalizedPath(localeKey)}>{t("home")}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={getLocalizedPath(localeKey, "/contact")}>{t("contact")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
+5
-1
@@ -21,5 +21,9 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
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",
|
||||
media: "Media",
|
||||
siteSettings: "SEO",
|
||||
smtp: "SMTP",
|
||||
portfolio: "Portfolio",
|
||||
logout: "Ausloggen",
|
||||
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 { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||
import { getLocalizedPath } from "@/lib/locale";
|
||||
import { getRootNavigation } from "@/lib/root-navigation";
|
||||
@@ -34,13 +35,16 @@ type RootDashboardCopy = {
|
||||
portfolio: string;
|
||||
media: string;
|
||||
siteSettings: string;
|
||||
smtp?: string;
|
||||
contactProtection?: string;
|
||||
logout: string;
|
||||
backToSite: string;
|
||||
};
|
||||
|
||||
type RootDashboardShellProps = {
|
||||
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";
|
||||
logoutAction: () => Promise<void>;
|
||||
headerTitle: string;
|
||||
@@ -58,6 +62,7 @@ type RootDashboardShellProps = {
|
||||
export async function RootDashboardShell({
|
||||
copy,
|
||||
active,
|
||||
smtpChild,
|
||||
portfolioChild,
|
||||
logoutAction,
|
||||
headerTitle,
|
||||
@@ -75,7 +80,7 @@ export async function RootDashboardShell({
|
||||
getSiteSettingsMediaBindings(),
|
||||
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",
|
||||
);
|
||||
const headerIcon =
|
||||
@@ -87,6 +92,8 @@ export async function RootDashboardShell({
|
||||
? SwatchBook
|
||||
: active === "site-settings"
|
||||
? Globe2
|
||||
: active === "smtp"
|
||||
? ShieldAlert
|
||||
: active === "media"
|
||||
? ImageIcon
|
||||
: portfolioChild === "categories"
|
||||
@@ -111,7 +118,7 @@ export async function RootDashboardShell({
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
icon={headerIcon}
|
||||
items={sidebarItems}
|
||||
items={normalizedSidebarItems}
|
||||
sidebarIconSrc={mediaBindings.favicon?.url}
|
||||
sidebarTop={
|
||||
<div className="space-y-2">
|
||||
@@ -149,6 +156,7 @@ export async function RootDashboardShell({
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
<Separator />
|
||||
</>
|
||||
}
|
||||
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 SiteSettingsMediaBindings,
|
||||
} 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 {
|
||||
DEFAULT_SITE_NAME,
|
||||
SITE_NAME_KEY,
|
||||
@@ -32,6 +50,24 @@ import {
|
||||
type SiteSettings,
|
||||
type SiteSettingsMediaBindings,
|
||||
} 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> {
|
||||
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> {
|
||||
try {
|
||||
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,
|
||||
ImageIcon,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
PlusSquare,
|
||||
ShieldAlert,
|
||||
SwatchBook,
|
||||
@@ -17,6 +18,8 @@ type RootNavigationCopy = {
|
||||
portfolio: string;
|
||||
media: string;
|
||||
siteSettings: string;
|
||||
smtp?: string;
|
||||
contactProtection?: string;
|
||||
};
|
||||
|
||||
export type RootNavItem = {
|
||||
@@ -30,7 +33,8 @@ export type RootNavItem = {
|
||||
|
||||
export function getRootNavigation(
|
||||
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",
|
||||
): RootNavItem[] {
|
||||
return [
|
||||
@@ -64,6 +68,27 @@ export function getRootNavigation(
|
||||
icon: Globe2,
|
||||
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,
|
||||
href: "/root/portfolio",
|
||||
|
||||
@@ -76,7 +76,10 @@
|
||||
"intro": "Schreib mir kurz dein Ziel und ich melde mich zeitnah.",
|
||||
"name": "Name",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"company": "Firma",
|
||||
"message": "Nachricht",
|
||||
"note": "Teile kurz Kontext, Ziel und Deadline mit, damit die Antwort praeziser ausfallen kann.",
|
||||
"submit": "Senden",
|
||||
"preview": "Success Seite ansehen",
|
||||
"city": "Berlin, Deutschland"
|
||||
|
||||
@@ -76,7 +76,10 @@
|
||||
"intro": "Share your goal and I will get back quickly.",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"company": "Company",
|
||||
"message": "Message",
|
||||
"note": "Share the context, goals, and any deadline so the reply can be more precise.",
|
||||
"submit": "Submit",
|
||||
"preview": "Open success page",
|
||||
"city": "Berlin, Germany"
|
||||
|
||||
Generated
+20
-2
@@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
@@ -23,6 +24,7 @@
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.8.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
@@ -3084,12 +3086,20 @@
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"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": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
|
||||
@@ -7495,6 +7505,15 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -9755,7 +9774,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
@@ -32,6 +33,7 @@
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.8.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"react": "^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: {
|
||||
assetId: "fav",
|
||||
url: "/uploads/media/site-settings/favicon.svg",
|
||||
version: "v1",
|
||||
},
|
||||
defaultOgImage: {
|
||||
assetId: "og",
|
||||
url: "/uploads/media/site-settings/default-og.png",
|
||||
version: "v1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(metadata.title).toBe("Studio Moh");
|
||||
expect(metadata.icons).toEqual({
|
||||
icon: [{ url: "/favicon.ico?v=default" }],
|
||||
shortcut: [{ url: "/favicon.ico?v=default" }],
|
||||
apple: [{ url: "/apple-icon.png?v=default" }],
|
||||
icon: [{ url: "/favicon.ico?v=v1" }],
|
||||
shortcut: [{ url: "/favicon.ico?v=v1" }],
|
||||
apple: [{ url: "/apple-icon.png?v=v1" }],
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
card: "summary_large_image",
|
||||
|
||||
Reference in New Issue
Block a user