diff --git a/CLAUDE.md b/CLAUDE.md index 25ba89a..0c8d43a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ The full routing rewrite logic lives in `lib/admin-routing.ts` and `middleware.t Prisma client is in `lib/prisma.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access Prisma. -`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, contact protection, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/contact-protection.ts`, `lib/marquee-settings.ts`. +`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/marquee-settings.ts`. ### Module boundaries @@ -82,7 +82,7 @@ Prisma client is in `lib/prisma.ts`. All DB access must go through server-side m | AppConfig aggregate | `lib/app-config.ts` | | Portfolio queries | `lib/portfolio.ts` | | Media handling | `lib/media.ts` | -| Contact flow | `lib/contact-guard.ts`, `lib/mail.ts` | +| Contact flow | `lib/mail.ts` | ### Documentation to read by task scope diff --git a/app/[locale]/(site)/contact/actions.ts b/app/[locale]/(site)/contact/actions.ts index 9f394ba..47c243c 100644 --- a/app/[locale]/(site)/contact/actions.ts +++ b/app/[locale]/(site)/contact/actions.ts @@ -4,8 +4,7 @@ import { redirect } from "next/navigation"; import { isRedirectError } from "next/dist/client/components/redirect-error"; import { z } from "zod"; -import { enforceContactRateLimit, verifyTurnstileToken } from "@/lib/contact-guard"; -import { getContactProtectionSettings, getSiteSettings } from "@/lib/app-config"; +import { getSiteSettings } from "@/lib/app-config"; import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { sendContactMessage } from "@/lib/mail"; @@ -16,55 +15,32 @@ const contactFormSchema = z.object({ 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.", - }, + ar: "تعذّر إرسال الرسالة. تأكد من تعبئة الحقول بشكل صحيح وحاول مجدداً.", + en: "Your message could not be sent. Please check the fields and try again.", + de: "Nachricht konnte nicht gesendet werden. Bitte Eingaben pruefen und erneut versuchen.", } as const; -const contactSuccessMessages = { - ar: "شكراً على رسالتك.", - en: "Thank you for your message.", - de: "Vielen Dank fuer deine Nachricht.", -} as const; - -function withMessage(pathname: string, type: "success" | "error", message: string) { - const params = new URLSearchParams(); - params.set(type, message); - - return `${pathname}?${params.toString()}`; -} - function getStringValue(formData: FormData, key: string) { const value = formData.get(key); return typeof value === "string" ? value : ""; } +function withContactError(pathname: string, message: string) { + const params = new URLSearchParams(); + params.set("error", message); + + return `${pathname}?${params.toString()}`; +} + export async function submitContactFormAction(formData: FormData) { const siteSettings = await getSiteSettings(); const locale = resolveLocale(String(formData.get("locale") ?? ""), siteSettings.defaultLocale); const contactPath = getLocalizedPath(locale, "/contact", siteSettings.defaultLocale); try { - const protectionSettings = await getContactProtectionSettings(); const values = contactFormSchema.parse({ locale, name: getStringValue(formData, "name"), @@ -72,12 +48,8 @@ export async function submitContactFormAction(formData: FormData) { 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, @@ -87,36 +59,13 @@ export async function submitContactFormAction(formData: FormData) { message: values.message, }); - redirect( - withMessage( - getLocalizedPath(locale, "/success", siteSettings.defaultLocale), - "success", - contactSuccessMessages[locale], - ), - ); + redirect(getLocalizedPath(locale, "/success", siteSettings.defaultLocale)); } catch (error) { if (isRedirectError(error)) { throw error; } - if (error instanceof z.ZodError) { - redirect(withMessage(contactPath, "error", contactErrorMessages[locale].invalid)); - } - - if (error instanceof Error && error.message === "Too many contact requests. Please try again later.") { - redirect(withMessage(contactPath, "error", 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(withMessage(contactPath, "error", contactErrorMessages[locale].verification)); - } - console.error("Contact form delivery failed.", error); - redirect(withMessage(contactPath, "error", contactErrorMessages[locale].failed)); + redirect(withContactError(contactPath, contactErrorMessages[locale])); } } diff --git a/app/[locale]/(site)/contact/page.tsx b/app/[locale]/(site)/contact/page.tsx index c379421..1753443 100644 --- a/app/[locale]/(site)/contact/page.tsx +++ b/app/[locale]/(site)/contact/page.tsx @@ -7,7 +7,7 @@ import { PageHero } from "@/components/layout/page-hero"; import { MotionFade } from "@/components/motion-fade"; import { ContactForm } from "@/components/site/contact-form"; import { buildLocalizedMetadata } from "@/lib/metadata"; -import { getPublicContactProtectionSettings, getSiteSettings } from "@/lib/app-config"; +import { getSiteSettings } from "@/lib/app-config"; import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { AppCard } from "@/components/ui/app-card"; import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -18,6 +18,9 @@ type ContactPageProps = { params: Promise<{ locale: string; }>; + searchParams?: Promise<{ + error?: string; + }>; }; export async function generateMetadata({ params }: ContactPageProps): Promise { @@ -34,14 +37,12 @@ export async function generateMetadata({ params }: ContactPageProps): Promise siteSettings.defaultLocale), siteSettings.defaultLocale); - const [t, protection] = await Promise.all([ - getTranslations({ locale: localeKey, namespace: "contactPage" }), - getPublicContactProtectionSettings(), - ]); + const t = await getTranslations({ locale: localeKey, namespace: "contactPage" }); + const contactError = (await searchParams)?.error; return ( <> @@ -52,6 +53,17 @@ export default async function ContactPage({ params }: ContactPageProps) { description={t("intro")} /> + {contactError ? ( + +

+ {contactError} +

+
+ ) : null} + @@ -85,7 +97,6 @@ export default async function ContactPage({ params }: ContactPageProps) { action={submitContactFormAction} locale={localeKey} previewHref={getLocalizedPath(localeKey, "/success", siteSettings.defaultLocale)} - protection={protection} copy={{ name: t("name"), email: t("email"), diff --git a/app/_admin/maintenance/page.tsx b/app/_admin/maintenance/page.tsx index 57a4755..312e1a4 100644 --- a/app/_admin/maintenance/page.tsx +++ b/app/_admin/maintenance/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { MotionFade } from "@/components/motion-fade"; import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell"; +import { readFlash } from "@/lib/admin-feedback"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getAdminAppPath } from "@/lib/admin-routing"; import { getMaintenanceMode } from "@/lib/app-config"; @@ -28,7 +29,13 @@ const copy = { backToSite: "Zur Website", }; -export default async function AdminMaintenancePage() { +export default async function AdminMaintenancePage({ + searchParams, +}: { + searchParams?: Promise<{ success?: string; error?: string }>; +}) { + const flash = readFlash(await searchParams); + const authenticated = await isAdminAuthenticated(); if (!authenticated) { @@ -47,6 +54,7 @@ export default async function AdminMaintenancePage() { ; +}) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -44,6 +51,7 @@ export default async function AdminMarqueePage() { 0) { - redirect(withMessage(getAdminAppPath("/media"), "error", "Datei wird noch verwendet.")); + redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." })); } await prisma.mediaAsset.delete({ @@ -90,13 +85,13 @@ export async function deleteMediaAssetAction(formData: FormData) { } revalidateMediaPages(); - redirect(withMessage(getAdminAppPath("/media"), "success", "Datei geloescht.")); + redirect(withFlash(getAdminAppPath("/media"), { success: "Datei geloescht." })); } catch (error) { if (isRedirectError(error)) { throw error; } const message = error instanceof Error ? error.message : "Datei konnte nicht geloescht werden."; - redirect(withMessage(getAdminAppPath("/media"), "error", message)); + redirect(withFlash(getAdminAppPath("/media"), { error: message })); } } diff --git a/app/_admin/media/page.tsx b/app/_admin/media/page.tsx index 4eff542..db713ae 100644 --- a/app/_admin/media/page.tsx +++ b/app/_admin/media/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { MediaLibraryManager } from "@/components/admin/media-library-manager"; import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell"; +import { readFlash } from "@/lib/admin-feedback"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getAdminAppPath } from "@/lib/admin-routing"; import { getAdminMediaAssets } from "@/lib/media"; @@ -21,7 +22,13 @@ const copy = { backToSite: "Zur Website", }; -export default async function AdminMediaPage() { +export default async function AdminMediaPage({ + searchParams, +}: { + searchParams?: Promise<{ success?: string; error?: string }>; +}) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -39,6 +46,7 @@ export default async function AdminMediaPage() { ; }; @@ -63,6 +65,7 @@ const copy = { export default async function AdminPage({ searchParams }: AdminPageProps) { const resolvedSearchParams = await searchParams; + const flash = readFlash(resolvedSearchParams); const authConfigured = isAdminAuthConfigured(); const basicConfigured = Boolean( process.env.ADMIN_BASIC_AUTH_USER && process.env.ADMIN_BASIC_AUTH_PASS, @@ -186,6 +189,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) { 0) { - redirect(withMessage(redirectPath, "error", "Kategorie mit Projekten kann nicht geloescht werden.")); + redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." })); } await prisma.category.delete({ @@ -180,13 +175,13 @@ export async function deleteCategoryAction(formData: FormData) { }); await revalidatePortfolioPages(); - redirect(withMessage(redirectPath, "success", "Kategorie geloescht.")); + redirect(withFlash(redirectPath, { success: "Kategorie geloescht." })); } catch (error) { if (isRedirectError(error)) { throw error; } - redirect(withMessage(redirectPath, "error", "Kategorie konnte nicht geloescht werden.")); + redirect(withFlash(redirectPath, { error: "Kategorie konnte nicht geloescht werden." })); } } @@ -529,7 +524,9 @@ export async function saveProjectAction(formData: FormData) { } redirect( - withMessage(getAdminAppPath(`/portfolio/projects/${projectResult.project.id}`), "success", "Projekt gespeichert."), + withFlash(getAdminAppPath(`/portfolio/projects/${projectResult.project.id}`), { + success: "Projekt gespeichert.", + }), ); } catch (error) { if (isRedirectError(error)) { @@ -562,7 +559,7 @@ export async function saveProjectAction(formData: FormData) { }, }); } - redirect(withMessage(redirectPath, "error", message)); + redirect(withFlash(redirectPath, { error: message })); } } @@ -582,7 +579,7 @@ export async function deleteProjectAction(formData: FormData) { }); if (!project) { - redirect(withMessage(getAdminAppPath("/portfolio"), "error", "Projekt nicht gefunden.")); + redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." })); } await prisma.portfolioProject.delete({ @@ -600,12 +597,12 @@ export async function deleteProjectAction(formData: FormData) { revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`, siteSettings.defaultLocale)); } - redirect(withMessage(getAdminAppPath("/portfolio"), "success", "Project deleted.")); + redirect(withFlash(getAdminAppPath("/portfolio"), { success: "Projekt geloescht." })); } catch (error) { if (isRedirectError(error)) { throw error; } - redirect(withMessage(getAdminAppPath("/portfolio"), "error", "Unable to delete project.")); + redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt konnte nicht geloescht werden." })); } } diff --git a/app/_admin/portfolio/categories/page.tsx b/app/_admin/portfolio/categories/page.tsx index 2f1fa98..3ad39c1 100644 --- a/app/_admin/portfolio/categories/page.tsx +++ b/app/_admin/portfolio/categories/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { PortfolioCategoriesManager } from "@/components/admin/portfolio-categories-manager"; import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell"; +import { readFlash } from "@/lib/admin-feedback"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getAdminAppPath } from "@/lib/admin-routing"; import { getAdminPortfolioCategories } from "@/lib/portfolio"; @@ -23,7 +24,13 @@ const copy = { backToSite: "Zur Website", }; -export default async function AdminPortfolioCategoriesPage() { +export default async function AdminPortfolioCategoriesPage({ + searchParams, +}: { + searchParams?: Promise<{ success?: string; error?: string }>; +}) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -43,6 +50,7 @@ export default async function AdminPortfolioCategoriesPage() { ; + searchParams?: Promise<{ success?: string; error?: string }>; }; export default async function AdminPortfolioProjectPage({ params, + searchParams, }: AdminPortfolioProjectPageProps) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -73,13 +78,14 @@ export default async function AdminPortfolioProjectPage({ ]); if (!project) { - redirect(`${getAdminAppPath("/portfolio")}?error=Project+not+found.`); + redirect(getAdminAppPath("/portfolio")); } return ( ; +}) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -46,6 +53,7 @@ export default async function AdminNewPortfolioProjectPage() { ; }; @@ -32,6 +35,7 @@ export default async function AdminPortfolioProjectsPage({ searchParams, }: AdminPortfolioProjectsPageProps) { const resolvedSearchParams = await searchParams; + const flash = readFlash(resolvedSearchParams); if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); @@ -62,6 +66,7 @@ export default async function AdminPortfolioProjectsPage({ ; +}) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -54,6 +61,7 @@ export default async function AdminSiteBrandSettingsPage() { ; +}) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -48,6 +55,7 @@ export default async function AdminSiteLocalizationSettingsPage() { -
- - - -
-
- ); -} diff --git a/app/_admin/smtp/page.tsx b/app/_admin/smtp/page.tsx index bb6d559..220d843 100644 --- a/app/_admin/smtp/page.tsx +++ b/app/_admin/smtp/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { MotionFade } from "@/components/motion-fade"; import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell"; +import { readFlash } from "@/lib/admin-feedback"; import { SMTPSettingsForm } from "@/components/admin/smtp-settings-form"; import { Button } from "@/components/ui/button"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; @@ -21,13 +22,18 @@ const copy = { media: "Media", siteSettings: "Settings", smtp: "SMTP", - contactProtection: "Contact Protection", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", }; -export default async function AdminSMTPPage() { +export default async function AdminSMTPPage({ + searchParams, +}: { + searchParams?: Promise<{ success?: string; error?: string }>; +}) { + const flash = readFlash(await searchParams); + if (!(await isAdminAuthenticated())) { redirect(getAdminAppPath("/")); } @@ -45,7 +51,7 @@ export default async function AdminSMTPPage() { ; +}) { + const flash = readFlash(await searchParams); + const authenticated = await isAdminAuthenticated(); if (!authenticated) { @@ -39,6 +46,7 @@ export default async function AdminUiKitPage() { {children} - -