From d9da86dd466568f0c1b139047c7e02de30dafe29 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 23:35:16 +0200 Subject: [PATCH 1/8] fix(admin): clear media upload input safely --- components/admin/MediaManager.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/admin/MediaManager.tsx b/components/admin/MediaManager.tsx index d36a4ab..1a5a023 100644 --- a/components/admin/MediaManager.tsx +++ b/components/admin/MediaManager.tsx @@ -2,7 +2,7 @@ /* eslint-disable @next/next/no-img-element */ -import { useEffect, useMemo, useState, type ChangeEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import type { MediaFile } from "@/lib/admin/media"; type SortKey = "date" | "name" | "size"; @@ -28,6 +28,7 @@ function sortFiles(files: MediaFile[], sort: SortKey) { } export default function MediaManager({ initialFiles }: { initialFiles: MediaFile[] }) { + const fileInputRef = useRef(null); const [files, setFiles] = useState(initialFiles); const [filter, setFilter] = useState("all"); const [sort, setSort] = useState("date"); @@ -78,7 +79,7 @@ export default function MediaManager({ initialFiles }: { initialFiles: MediaFile if (!response.ok) throw new Error(result.message ?? "Upload fehlgeschlagen."); if (result.files) setFiles(result.files); setToast(result.message ?? "Datei erfolgreich hochgeladen."); - event.currentTarget.value = ""; + if (fileInputRef.current) fileInputRef.current.value = ""; } catch (err) { setError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); } finally { @@ -140,7 +141,7 @@ export default function MediaManager({ initialFiles }: { initialFiles: MediaFile
diff --git a/app/api/media/[filename]/route.ts b/app/api/media/[filename]/route.ts new file mode 100644 index 0000000..4d4c315 --- /dev/null +++ b/app/api/media/[filename]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { readMediaFile } from "@/lib/admin/media"; + +export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) { + const { filename } = await params; + const media = await readMediaFile(decodeURIComponent(filename)); + + if (!media) { + return NextResponse.json({ message: "Datei nicht gefunden." }, { status: 404 }); + } + + return new NextResponse(media.data, { + status: 200, + headers: { + "Content-Type": media.mimeType, + "Content-Length": String(media.size), + "Content-Disposition": "inline", + "Cache-Control": "private, max-age=300", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 47148c1..4b670a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,11 @@ services: DOCKER_ENV: "true" volumes: - funktechnik-data:/app/data - - funktechnik-uploads:/app/public/uploads/images + - funktechnik-storage:/app/storage - funktechnik-next-cache:/app/.next/cache restart: unless-stopped volumes: funktechnik-data: - funktechnik-uploads: + funktechnik-storage: funktechnik-next-cache: diff --git a/lib/admin/media.ts b/lib/admin/media.ts index 411b718..74a6e37 100644 --- a/lib/admin/media.ts +++ b/lib/admin/media.ts @@ -1,7 +1,7 @@ import { randomUUID } from "crypto"; -import { mkdir, readdir, rm, stat, writeFile } from "fs/promises"; +import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises"; import path from "path"; -import { uploadDirectory } from "@/lib/runtime/config"; +import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config"; const maxUploadSize = 5 * 1024 * 1024; @@ -23,6 +23,15 @@ export type MediaFile = { isPdf: boolean; }; +export function getMediaMimeType(fileName: string) { + const extension = path.extname(fileName).toLowerCase(); + if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg"; + if (extension === ".png") return "image/png"; + if (extension === ".webp") return "image/webp"; + if (extension === ".pdf") return "application/pdf"; + return ""; +} + export async function ensureUploadDirectory() { await mkdir(uploadDirectory, { recursive: true }); const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`); @@ -81,6 +90,7 @@ function isAllowedStoredFile(fileName: string) { } export async function listMediaFiles(): Promise { + await migrateLegacyUploads(); await mkdir(uploadDirectory, { recursive: true }); const entries = await readdir(uploadDirectory); const visibleEntries = entries.filter(isAllowedStoredFile); @@ -92,7 +102,7 @@ export async function listMediaFiles(): Promise { return { name: entry, - url: `/uploads/images/${encodeURIComponent(entry)}`, + url: `/api/media/${encodeURIComponent(entry)}`, type: isPdf ? "pdf" as const : "image" as const, extension: extension.replace(".", "").toUpperCase() || "Datei", size: fileStat.size, @@ -117,6 +127,24 @@ export async function saveMediaFile(file: File) { return { fileName: safeName }; } +export async function readMediaFile(fileName: string) { + if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { + return null; + } + + const mimeType = getMediaMimeType(fileName); + if (!mimeType) return null; + + const target = resolveUploadTarget(fileName); + + try { + const [data, fileStat] = await Promise.all([readFile(target), stat(target)]); + return { data, mimeType, size: fileStat.size, uploadedAt: fileStat.birthtime.toISOString() }; + } catch { + return null; + } +} + export async function deleteMediaFile(fileName: string) { if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { return { error: "Ungültiger Dateiname." }; diff --git a/lib/runtime/config.ts b/lib/runtime/config.ts index a412dcd..42eab92 100644 --- a/lib/runtime/config.ts +++ b/lib/runtime/config.ts @@ -1,10 +1,12 @@ import { existsSync } from "fs"; -import { access, mkdir, writeFile, rm } from "fs/promises"; +import { access, copyFile, mkdir, readdir, rm, writeFile } from "fs/promises"; import path from "path"; import { appVersion } from "./version"; export const dataDirectory = path.join(process.cwd(), "data"); -export const uploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); +export const storageDirectory = path.join(process.cwd(), "storage"); +export const uploadDirectory = path.join(storageDirectory, "uploads", "images"); +export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); export function adminConfigurationStatus() { return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing"; @@ -17,6 +19,7 @@ export function isDockerEnvironment() { export async function ensureRuntimeDirectories() { await mkdir(dataDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true }); + await migrateLegacyUploads(); } export async function checkStorage() { @@ -36,4 +39,20 @@ export function smtpStatus() { return "prepared"; } +export async function migrateLegacyUploads() { + if (!existsSync(legacyPublicUploadDirectory)) return; + + await mkdir(uploadDirectory, { recursive: true }); + const entries = await readdir(legacyPublicUploadDirectory, { withFileTypes: true }); + + await Promise.all(entries.map(async (entry) => { + if (!entry.isFile() || entry.name.startsWith(".")) return; + + const source = path.join(legacyPublicUploadDirectory, entry.name); + const target = path.join(uploadDirectory, entry.name); + + if (!existsSync(target)) await copyFile(source, target); + })); +} + export { appVersion }; diff --git a/scripts/backup.sh b/scripts/backup.sh index 6430d9b..3214006 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -6,7 +6,7 @@ STAMP="$(date +%Y%m%d-%H%M%S)" TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz" mkdir -p "$BACKUP_DIR" -tar -czf "$TARGET" data public/uploads +tar -czf "$TARGET" data storage echo "Backup erstellt: $TARGET" echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert." diff --git a/scripts/runtime-start.js b/scripts/runtime-start.js index 31eb0a8..c9c835f 100644 --- a/scripts/runtime-start.js +++ b/scripts/runtime-start.js @@ -5,7 +5,7 @@ const path = require("path"); const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredDirs = [ path.join(process.cwd(), "data"), - path.join(process.cwd(), "public", "uploads", "images"), + path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), ".next", "cache"), ]; @@ -21,6 +21,26 @@ function ensureWritableDirectory(directory) { fs.rmSync(probe, { force: true }); } +function migrateLegacyUploads() { + const legacyDirectory = path.join(process.cwd(), "public", "uploads", "images"); + const storageDirectory = path.join(process.cwd(), "storage", "uploads", "images"); + + if (!fs.existsSync(legacyDirectory)) return; + + fs.mkdirSync(storageDirectory, { recursive: true }); + for (const fileName of fs.readdirSync(legacyDirectory)) { + if (fileName.startsWith(".")) continue; + + const source = path.join(legacyDirectory, fileName); + const target = path.join(storageDirectory, fileName); + const sourceStat = fs.statSync(source); + + if (sourceStat.isFile() && !fs.existsSync(target)) { + fs.copyFileSync(source, target); + } + } +} + const missingEnv = requiredEnv.filter((name) => !process.env[name]); if (missingEnv.length > 0) { fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`); @@ -28,6 +48,7 @@ if (missingEnv.length > 0) { try { for (const directory of requiredDirs) ensureWritableDirectory(directory); + migrateLegacyUploads(); } catch (error) { fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); } diff --git a/public/uploads/.gitkeep b/storage/.gitkeep similarity index 100% rename from public/uploads/.gitkeep rename to storage/.gitkeep diff --git a/public/uploads/images/.gitkeep b/storage/uploads/.gitkeep similarity index 100% rename from public/uploads/images/.gitkeep rename to storage/uploads/.gitkeep diff --git a/storage/uploads/images/.gitkeep b/storage/uploads/images/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/uploads/images/.gitkeep @@ -0,0 +1 @@ + From 3456b04ed4c6fad8a16154dbad9908606f08092c Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 00:06:20 +0200 Subject: [PATCH 3/8] feat(mail): add smtp email delivery --- .dockerignore | 2 + .gitignore | 2 + Dockerfile | 2 +- README.md | 49 ++++++++++-- app/admin/page.tsx | 5 +- app/admin/smtp/page.tsx | 14 +--- app/admin/system/page.tsx | 12 ++- app/api/admin/smtp/route.ts | 23 +++++- app/api/contact/route.ts | 4 +- app/api/health/route.ts | 4 + app/api/repair/route.ts | 4 +- components/admin/SmtpSettingsForm.tsx | 94 ++++++++++++++++++++++ docker-compose.yml | 2 +- lib/admin/store.ts | 28 ++----- lib/admin/types.ts | 10 ++- lib/mail/config.ts | 109 ++++++++++++++++++++++++++ lib/mail/smtp.ts | 100 +++++++++++++++++++++++ lib/mail/templates.ts | 74 +++++++++++++++++ lib/mail/types.ts | 13 +++ lib/runtime/config.ts | 2 + lib/runtime/version.ts | 2 +- package-lock.json | 26 +++++- package.json | 4 +- scripts/runtime-start.js | 3 +- storage/config/.gitkeep | 1 + 25 files changed, 529 insertions(+), 60 deletions(-) create mode 100644 components/admin/SmtpSettingsForm.tsx create mode 100644 lib/mail/config.ts create mode 100644 lib/mail/smtp.ts create mode 100644 lib/mail/templates.ts create mode 100644 lib/mail/types.ts create mode 100644 storage/config/.gitkeep diff --git a/.dockerignore b/.dockerignore index fb3f6f5..145fa92 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,8 @@ npm-debug.log* README.md data/*.json storage/uploads/images/* +storage/config/* +!storage/config/.gitkeep !storage/uploads/images/.gitkeep *.tmp *.temp diff --git a/.gitignore b/.gitignore index cbe8f57..204a542 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ data/repair-inquiries.json data/site-settings.json data/smtp-settings.json storage/uploads/images/* +storage/config/* !storage/.gitkeep +!storage/config/.gitkeep !storage/uploads/.gitkeep !storage/uploads/images/.gitkeep .DS_Store diff --git a/Dockerfile b/Dockerfile index 60c8b8b..c0bb863 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js -RUN mkdir -p /app/data /app/storage/uploads/images /app/.next/cache \ +RUN mkdir -p /app/data /app/storage/config /app/storage/uploads/images /app/.next/cache \ && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache USER nextjs diff --git a/README.md b/README.md index 329ed24..4c9a96d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt -Version: `0.2.2` +Version: `0.3.0` ## Seiten @@ -140,12 +140,12 @@ Funktionen: - Reparaturanfragen verwalten - Website-Inhaltsverwaltung vorbereitet - Firmendaten pflegen -- SMTP-Konfiguration vorbereiten +- SMTP-Konfiguration speichern und Testmail senden - Medien hochladen - SEO-Übersicht - Systemübersicht -SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. +Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist. ## Runtime Data @@ -163,9 +163,10 @@ Echte Runtime-Dateien werden nicht committed: data/contact-inquiries.json data/repair-inquiries.json data/site-settings.json -data/smtp-settings.json ``` +SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben. + Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed. Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind. @@ -187,13 +188,50 @@ Antwort: ```json { "status": "ok", - "version": "0.2.2", + "version": "0.3.0", "storage": "ok", "admin": "configured", + "smtp": "configured", "timestamp": "..." } ``` +## SMTP + +SMTP wird im Adminbereich unter `/admin/smtp` konfiguriert. Die Konfiguration wird persistent unter `storage/config/smtp.json` gespeichert und liegt damit im Docker-Storage-Volume, nicht in `public/`. + +Pflichtfelder: + +- SMTP Host +- SMTP Port +- SMTP Benutzername +- SMTP Passwort +- Verschlüsselung +- Absenderadresse +- Empfängeradresse + +Für Apple Mail/iCloud Mail: + +```text +Host: smtp.mail.me.com +Port: 587 +Verschlüsselung: STARTTLS +Benutzername: vollständige E-Mail-Adresse +Passwort: app-spezifisches Passwort +``` + +Nicht das normale Apple-ID-Passwort verwenden. In der Apple-ID-Verwaltung ein app-spezifisches Passwort erzeugen und dieses als SMTP-Passwort speichern. + +Nach dem Speichern kann im Adminbereich eine Testmail gesendet werden. Kontakt- und Reparaturanfragen werden weiterhin lokal gespeichert. Wenn SMTP konfiguriert ist, wird zusaetzlich eine E-Mail an die konfigurierte Empfaengeradresse gesendet. Schlaegt der Mailversand fehl, bleibt die Anfrage gespeichert; der Besucher sieht keine technische Fehlermeldung. + +Troubleshooting: + +- Host, Port und Verschlüsselung prüfen +- bei Apple Mail STARTTLS und Port 587 verwenden +- vollständige E-Mail-Adresse als Benutzername verwenden +- app-spezifisches Passwort neu erzeugen +- letzte Testmail und letzte Fehlermeldung unter `/admin/smtp` prüfen + ## Deployment ```bash @@ -259,6 +297,5 @@ certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de ## Offene manuelle Punkte - TODO: Rechtliche Angaben ergänzen -- Produktives Kontakt-/Mail-System anbinden - Spätere Olympus-Reparaturannahme serverseitig anbinden - Finale Domain und HTTPS-Konfiguration setzen diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b479b0d..9142ef2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; function countNew(items: T[]) { return items.filter((item) => item.status === "new").length; @@ -9,7 +10,7 @@ function countNew(items: T[]) { export default async function AdminDashboardPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [contacts, repairs] = await Promise.all([getContactInquiries(), getRepairInquiries()]); + const [contacts, repairs, smtpSettings] = await Promise.all([getContactInquiries(), getRepairInquiries(), getSmtpSettings()]); const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6); return ( @@ -22,7 +23,7 @@ export default async function AdminDashboardPage() {
Kontaktanfragen{countNew(contacts)}neu
Reparaturanfragen{countNew(repairs)}neu
WebsiteOnlineOK
-
SMTPVorbereitetnicht aktiv
+
SMTP{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}{smtpSettings.lastTestStatus ?? "kein Test"}

Letzte Anfragen

diff --git a/app/admin/smtp/page.tsx b/app/admin/smtp/page.tsx index ae13637..662eeb9 100644 --- a/app/admin/smtp/page.tsx +++ b/app/admin/smtp/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm"; import { requireAdminSession } from "@/lib/admin/auth"; -import { getSmtpSettings } from "@/lib/admin/store"; +import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config"; export default async function AdminSmtpPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -13,16 +14,7 @@ export default async function AdminSmtpPage() {

E-Mail

SMTP Einstellungen

-
- - - - - - - - -
+ ); } diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index d71e400..c6f59ff 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; -import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config"; +import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -12,6 +13,8 @@ export default async function AdminSystemPage() { } catch { storage = "error"; } + const smtpSettings = await getSmtpSettings(); + const smtpConfigured = isSmtpConfigured(smtpSettings); return ( @@ -27,10 +30,13 @@ export default async function AdminSystemPage() {
Docker Environment
{isDockerEnvironment() ? "ja" : "nein"}
Storage Status
{storage}
Data Directory
{dataDirectory}
+
Config Directory
{configDirectory}
Upload Directory
{uploadDirectory}
Admin Konfiguration
{adminConfigurationStatus()}
Olympus Verbindung
{olympusStatus()}
-
SMTP
{smtpStatus()}
+
SMTP
{smtpConfigured ? "configured" : "missing"}
+
Letzte SMTP-Testmail
{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}
+
Letzter SMTP-Formularversand
{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}
@@ -38,7 +44,7 @@ export default async function AdminSystemPage() {
  • Runtime-Daten liegen unter data/ und werden nicht versioniert.
  • Uploads liegen unter storage/uploads/images/ und werden nicht versioniert.
  • -
  • Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.
  • +
  • SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.
diff --git a/app/api/admin/smtp/route.ts b/app/api/admin/smtp/route.ts index ea05bb2..5b4f368 100644 --- a/app/api/admin/smtp/route.ts +++ b/app/api/admin/smtp/route.ts @@ -1,9 +1,26 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { saveSmtpSettings } from "@/lib/admin/store"; +import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config"; +import { sendTestMail } from "@/lib/mail/smtp"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await saveSmtpSettings(await request.formData()); - return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); + const form = await request.formData(); + const action = String(form.get("action") ?? "save"); + + if (action === "test") { + const settings = await saveSmtpSettings(form); + const result = await sendTestMail(); + return NextResponse.json({ + message: result.ok ? "Test-E-Mail wurde gesendet." : result.error ?? "Test-E-Mail fehlgeschlagen.", + ok: result.ok, + settings: toPublicSmtpSettings(settings), + }, { status: result.ok ? 200 : 400 }); + } + + const settings = await saveSmtpSettings(form); + return NextResponse.json({ + message: "SMTP-Konfiguration gespeichert.", + settings: toPublicSmtpSettings(settings), + }); } diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index ee384f7..07555e4 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addContactInquiry } from "@/lib/admin/store"; +import { sendContactInquiryMail } from "@/lib/mail/smtp"; function text(value: FormDataEntryValue | null) { return typeof value === "string" ? value.trim() : ""; @@ -16,7 +17,8 @@ export async function POST(request: Request) { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addContactInquiry(form); + const inquiry = await addContactInquiry(form); + await sendContactInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.", diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 30791a0..68745af 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; export async function GET() { let storage = "ok"; @@ -10,11 +11,14 @@ export async function GET() { storage = "error"; } + const smtpSettings = await getSmtpSettings(); + return NextResponse.json({ status: storage === "ok" ? "ok" : "error", version: appVersion, storage, admin: adminConfigurationStatus(), + smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing", timestamp: new Date().toISOString(), }); } diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts index 6b98806..5fb6c1b 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addRepairInquiry } from "@/lib/admin/store"; +import { sendRepairInquiryMail } from "@/lib/mail/smtp"; function required(value: FormDataEntryValue | null) { return typeof value === "string" && value.trim().length > 0; @@ -19,7 +20,8 @@ export async function POST(request: Request) { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addRepairInquiry(form); + const inquiry = await addRepairInquiry(form); + await sendRepairInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.", diff --git a/components/admin/SmtpSettingsForm.tsx b/components/admin/SmtpSettingsForm.tsx new file mode 100644 index 0000000..359cdae --- /dev/null +++ b/components/admin/SmtpSettingsForm.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useRef, useState, type FormEvent } from "react"; +import type { PublicSmtpSettings } from "@/lib/mail/types"; + +type ApiResponse = { + message?: string; + ok?: boolean; + settings?: PublicSmtpSettings; +}; + +export default function SmtpSettingsForm({ initialSettings, configured }: { initialSettings: PublicSmtpSettings; configured: boolean }) { + const formRef = useRef(null); + const [settings, setSettings] = useState(initialSettings); + const [isConfigured, setIsConfigured] = useState(configured); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submitForm(form: HTMLFormElement, action: "save" | "test") { + setMessage(""); + setError(""); + setPending(true); + + const formData = new FormData(form); + formData.set("action", action); + + try { + const response = await fetch("/api/admin/smtp", { method: "POST", body: formData }); + const result = await response.json() as ApiResponse; + if (result.settings) { + setSettings(result.settings); + setIsConfigured(Boolean(result.settings.host && result.settings.username && result.settings.hasPassword && result.settings.fromAddress && result.settings.recipientAddress)); + } + if (!response.ok) throw new Error(result.message ?? "SMTP-Aktion fehlgeschlagen."); + setMessage(result.message ?? "Aktion erfolgreich."); + } catch (err) { + setError(err instanceof Error ? err.message : "SMTP-Aktion fehlgeschlagen."); + } finally { + setPending(false); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void submitForm(event.currentTarget, "save"); + } + + function test() { + if (formRef.current) void submitForm(formRef.current, "test"); + } + + return ( + <> +
+

Status

+

SMTP ist {isConfigured ? "konfiguriert" : "nicht konfiguriert"}.

+ {settings.lastTestAt &&

Letzte Testmail: {new Date(settings.lastTestAt).toLocaleString("de-DE")} ({settings.lastTestStatus})

} + {settings.lastDeliveryAt &&

Letzter Formularversand: {new Date(settings.lastDeliveryAt).toLocaleString("de-DE")} ({settings.lastDeliveryStatus})

} + {settings.lastError &&

Letzter Fehler: {settings.lastError}

} +

Für Apple Mail/iCloud bitte smtp.mail.me.com, Port 587, STARTTLS und ein app-spezifisches Passwort verwenden.

+
+ + {message &&

{message}

} + {error &&

{error}

} + +
+ + + + + + + + + +
+ + +
+
+ + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 4b670a7..b31f6ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: - "3010:3010" environment: NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} - NEXT_PUBLIC_APP_VERSION: "0.2.2" + NEXT_PUBLIC_APP_VERSION: "0.3.0" ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} diff --git a/lib/admin/store.ts b/lib/admin/store.ts index 02757a8..28182c2 100644 --- a/lib/admin/store.ts +++ b/lib/admin/store.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from "fs/promises"; import path from "path"; -import { dataDirectory } from "@/lib/runtime/config"; -import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings, SmtpSettings } from "./types"; +import { configDirectory, dataDirectory } from "@/lib/runtime/config"; +import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types"; async function ensureDataDir() { await mkdir(dataDirectory, { recursive: true }); @@ -112,26 +112,8 @@ export async function saveSiteSettings(form: FormData) { return settings; } -export async function getSmtpSettings() { - return readJson("smtp-settings.json", { - host: "", - port: "587", - username: "", - fromAddress: "", - replyToAddress: "", - tls: true, - }); +export async function ensureConfigDir() { + await mkdir(configDirectory, { recursive: true }); } -export async function saveSmtpSettings(form: FormData) { - const settings: SmtpSettings = { - host: text(form.get("host")), - port: text(form.get("port")), - username: text(form.get("username")), - fromAddress: text(form.get("fromAddress")), - replyToAddress: text(form.get("replyToAddress")), - tls: form.get("tls") === "on", - }; - await writeJson("smtp-settings.json", settings); - return settings; -} +export { text }; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 11ae88b..3411cde 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -42,7 +42,15 @@ export type SmtpSettings = { host: string; port: string; username: string; + password?: string; + security: "starttls" | "tls" | "none"; fromAddress: string; replyToAddress: string; - tls: boolean; + recipientAddress: string; + bccAddress?: string; + lastTestAt?: string; + lastTestStatus?: "success" | "error"; + lastDeliveryAt?: string; + lastDeliveryStatus?: "success" | "error"; + lastError?: string; }; diff --git a/lib/mail/config.ts b/lib/mail/config.ts new file mode 100644 index 0000000..c08a6ff --- /dev/null +++ b/lib/mail/config.ts @@ -0,0 +1,109 @@ +import { mkdir, readFile, writeFile } from "fs/promises"; +import path from "path"; +import { configDirectory } from "@/lib/runtime/config"; +import type { SmtpSettings } from "@/lib/admin/types"; +import type { PublicSmtpSettings } from "./types"; + +const smtpConfigFile = path.join(configDirectory, "smtp.json"); + +const defaultSmtpSettings: SmtpSettings = { + host: "", + port: "587", + username: "", + password: "", + security: "starttls", + fromAddress: "", + replyToAddress: "", + recipientAddress: "", + bccAddress: "", +}; + +function text(value: FormDataEntryValue | null) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeSecurity(value: string): SmtpSettings["security"] { + if (value === "tls" || value === "none") return value; + return "starttls"; +} + +async function ensureConfigDirectory() { + await mkdir(configDirectory, { recursive: true }); +} + +export async function getSmtpSettings(): Promise { + await ensureConfigDirectory(); + try { + const file = await readFile(smtpConfigFile, "utf8"); + return { ...defaultSmtpSettings, ...JSON.parse(file) as SmtpSettings }; + } catch { + return defaultSmtpSettings; + } +} + +export function toPublicSmtpSettings(settings: SmtpSettings): PublicSmtpSettings { + return { + host: settings.host, + port: settings.port, + username: settings.username, + security: settings.security, + fromAddress: settings.fromAddress, + replyToAddress: settings.replyToAddress, + recipientAddress: settings.recipientAddress, + bccAddress: settings.bccAddress, + lastTestAt: settings.lastTestAt, + lastTestStatus: settings.lastTestStatus, + lastError: settings.lastError, + hasPassword: Boolean(settings.password), + }; +} + +export function isSmtpConfigured(settings: SmtpSettings) { + return Boolean( + settings.host && + settings.port && + settings.username && + settings.password && + settings.fromAddress && + settings.recipientAddress, + ); +} + +export async function saveSmtpSettings(form: FormData) { + const current = await getSmtpSettings(); + const nextPassword = text(form.get("password")); + const settings: SmtpSettings = { + ...current, + host: text(form.get("host")), + port: text(form.get("port")) || "587", + username: text(form.get("username")), + password: nextPassword || current.password || "", + security: normalizeSecurity(text(form.get("security"))), + fromAddress: text(form.get("fromAddress")), + replyToAddress: text(form.get("replyToAddress")), + recipientAddress: text(form.get("recipientAddress")), + bccAddress: text(form.get("bccAddress")), + }; + + await ensureConfigDirectory(); + await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + return settings; +} + +export async function updateSmtpTestStatus(status: Pick) { + const current = await getSmtpSettings(); + const settings: SmtpSettings = { ...current, ...status }; + await ensureConfigDirectory(); + await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + return settings; +} + +export async function updateSmtpDeliveryStatus(status: Pick) { + const current = await getSmtpSettings(); + const settings: SmtpSettings = { ...current, ...status }; + await ensureConfigDirectory(); + await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + return settings; +} + +export { smtpConfigFile }; diff --git a/lib/mail/smtp.ts b/lib/mail/smtp.ts new file mode 100644 index 0000000..4c3925a --- /dev/null +++ b/lib/mail/smtp.ts @@ -0,0 +1,100 @@ +import nodemailer from "nodemailer"; +import type SMTPTransport from "nodemailer/lib/smtp-transport"; +import type { SmtpSettings } from "@/lib/admin/types"; +import { getSmtpSettings, isSmtpConfigured, updateSmtpDeliveryStatus, updateSmtpTestStatus } from "./config"; +import { contactTemplate, repairTemplate, testTemplate } from "./templates"; +import type { ContactMailInput, MailSendResult, RepairMailInput } from "./types"; + +function sanitizeMailError(error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter SMTP-Fehler"; + return message + .replace(/AUTH PLAIN\s+\S+/gi, "AUTH PLAIN [redacted]") + .replace(/AUTH LOGIN\s+\S+/gi, "AUTH LOGIN [redacted]") + .replace(/password[=:]\S+/gi, "password=[redacted]") + .slice(0, 500); +} + +function createTransport(settings: SmtpSettings) { + const secure = settings.security === "tls"; + const requireTLS = settings.security === "starttls"; + const options: SMTPTransport.Options = { + host: settings.host, + port: Number(settings.port), + secure, + requireTLS, + auth: { + user: settings.username, + pass: settings.password, + }, + }; + + if (settings.security === "none") { + options.auth = settings.username && settings.password ? options.auth : undefined; + options.ignoreTLS = true; + } + + return nodemailer.createTransport(options); +} + +async function sendConfiguredMail(input: { + subject: string; + text: string; + html: string; + replyTo?: string; +}): Promise { + const settings = await getSmtpSettings(); + if (!isSmtpConfigured(settings)) return { ok: false, error: "SMTP nicht konfiguriert." }; + + try { + const transport = createTransport(settings); + await transport.sendMail({ + from: settings.fromAddress, + to: settings.recipientAddress, + bcc: settings.bccAddress || undefined, + replyTo: input.replyTo || settings.replyToAddress || undefined, + subject: input.subject, + text: input.text, + html: input.html, + }); + return { ok: true }; + } catch (error) { + return { ok: false, error: sanitizeMailError(error) }; + } +} + +export async function sendTestMail() { + const template = testTemplate(); + const result = await sendConfiguredMail(template); + await updateSmtpTestStatus({ + lastTestAt: new Date().toISOString(), + lastTestStatus: result.ok ? "success" : "error", + lastError: result.ok ? "" : result.error, + }); + return result; +} + +export async function sendContactInquiryMail(input: ContactMailInput) { + const template = contactTemplate(input); + const result = await sendConfiguredMail({ ...template, replyTo: input.email }); + if (result.error !== "SMTP nicht konfiguriert.") { + await updateSmtpDeliveryStatus({ + lastDeliveryAt: new Date().toISOString(), + lastDeliveryStatus: result.ok ? "success" : "error", + lastError: result.ok ? "" : result.error, + }); + } + return result; +} + +export async function sendRepairInquiryMail(input: RepairMailInput) { + const template = repairTemplate(input); + const result = await sendConfiguredMail({ ...template, replyTo: input.email }); + if (result.error !== "SMTP nicht konfiguriert.") { + await updateSmtpDeliveryStatus({ + lastDeliveryAt: new Date().toISOString(), + lastDeliveryStatus: result.ok ? "success" : "error", + lastError: result.ok ? "" : result.error, + }); + } + return result; +} diff --git a/lib/mail/templates.ts b/lib/mail/templates.ts new file mode 100644 index 0000000..fd1ba44 --- /dev/null +++ b/lib/mail/templates.ts @@ -0,0 +1,74 @@ +import type { ContactMailInput, RepairMailInput } from "./types"; + +function escapeHtml(value?: string) { + return (value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function rows(entries: Array<[string, string | undefined]>) { + return entries + .filter(([, value]) => value) + .map(([label, value]) => `${escapeHtml(label)}${escapeHtml(value)}`) + .join(""); +} + +function textRows(entries: Array<[string, string | undefined]>) { + return entries + .filter(([, value]) => value) + .map(([label, value]) => `${label}: ${value}`) + .join("\n"); +} + +export function contactTemplate(input: ContactMailInput) { + const subject = `Kontaktanfrage: ${input.subject ?? "Funktechnik Schubert"}`; + const entries: Array<[string, string | undefined]> = [ + ["Datum", new Date(input.createdAt).toLocaleString("de-DE")], + ["Name", input.name], + ["E-Mail", input.email], + ["Telefon", input.phone], + ["Betreff", input.subject], + ["Nachricht", input.message], + ]; + + return { + subject, + text: `Neue Kontaktanfrage\n\n${textRows(entries)}`, + html: `

Neue Kontaktanfrage

${rows(entries)}
`, + }; +} + +export function repairTemplate(input: RepairMailInput) { + const subject = `Reparaturanfrage: ${input.manufacturer} ${input.model}`; + const entries: Array<[string, string | undefined]> = [ + ["Datum", new Date(input.createdAt).toLocaleString("de-DE")], + ["Name", input.name], + ["E-Mail", input.email], + ["Telefon", input.phone], + ["Hersteller", input.manufacturer], + ["Modell", input.model], + ["Geräteart", input.deviceType], + ["Seriennummer", input.serialNumber], + ["Fehlerbeschreibung", input.description], + ["Zubehör", input.accessories], + ["Gerät geöffnet?", input.opened], + ["Vorarbeiten", input.previousWork], + ]; + + return { + subject, + text: `Neue Reparaturanfrage\n\n${textRows(entries)}`, + html: `

Neue Reparaturanfrage

${rows(entries)}
`, + }; +} + +export function testTemplate() { + return { + subject: "SMTP Testmail - Funktechnik Schubert", + text: "Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.", + html: "

SMTP Testmail

Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.

", + }; +} diff --git a/lib/mail/types.ts b/lib/mail/types.ts new file mode 100644 index 0000000..c9448bb --- /dev/null +++ b/lib/mail/types.ts @@ -0,0 +1,13 @@ +import type { ContactInquiry, RepairInquiry, SmtpSettings } from "@/lib/admin/types"; + +export type PublicSmtpSettings = Omit & { + hasPassword: boolean; +}; + +export type MailSendResult = { + ok: boolean; + error?: string; +}; + +export type ContactMailInput = ContactInquiry; +export type RepairMailInput = RepairInquiry; diff --git a/lib/runtime/config.ts b/lib/runtime/config.ts index 42eab92..84f0f29 100644 --- a/lib/runtime/config.ts +++ b/lib/runtime/config.ts @@ -5,6 +5,7 @@ import { appVersion } from "./version"; export const dataDirectory = path.join(process.cwd(), "data"); export const storageDirectory = path.join(process.cwd(), "storage"); +export const configDirectory = path.join(storageDirectory, "config"); export const uploadDirectory = path.join(storageDirectory, "uploads", "images"); export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); @@ -18,6 +19,7 @@ export function isDockerEnvironment() { export async function ensureRuntimeDirectories() { await mkdir(dataDirectory, { recursive: true }); + await mkdir(configDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true }); await migrateLegacyUploads(); } diff --git a/lib/runtime/version.ts b/lib/runtime/version.ts index e8563d7..dcc838e 100644 --- a/lib/runtime/version.ts +++ b/lib/runtime/version.ts @@ -1 +1 @@ -export const appVersion = "0.2.2"; +export const appVersion = "0.3.0"; diff --git a/package-lock.json b/package-lock.json index 01bef7c..bed248b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "dependencies": { + "@types/nodemailer": "^8.0.1", "next": "16.2.10", + "nodemailer": "^9.0.3", "react": "19.2.3", "react-dom": "19.2.3" }, @@ -1343,12 +1345,20 @@ "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -4669,6 +4679,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5912,7 +5931,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 23eedb3..b917003 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "private": true, "scripts": { "dev": "next dev -p 3010", @@ -9,7 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@types/nodemailer": "^8.0.1", "next": "16.2.10", + "nodemailer": "^9.0.3", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/scripts/runtime-start.js b/scripts/runtime-start.js index c9c835f..805ae13 100644 --- a/scripts/runtime-start.js +++ b/scripts/runtime-start.js @@ -5,6 +5,7 @@ const path = require("path"); const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredDirs = [ path.join(process.cwd(), "data"), + path.join(process.cwd(), "storage", "config"), path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), ".next", "cache"), ]; @@ -53,5 +54,5 @@ try { fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); } -process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.2.2"}.\n`); +process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.3.0"}.\n`); require("./server.js"); diff --git a/storage/config/.gitkeep b/storage/config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/config/.gitkeep @@ -0,0 +1 @@ + From 7d5689cfa4c63b0adb73a404feaf0d1d4a9bc2c4 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 00:31:13 +0200 Subject: [PATCH 4/8] fix(forms): repair public form validation and reset --- app/api/contact/route.ts | 2 +- app/api/repair/route.ts | 4 ++-- components/ContactForm.tsx | 14 ++++++++------ components/RepairForm.tsx | 21 +++++++++++++-------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 07555e4..6ea6537 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -13,7 +13,7 @@ export async function POST(request: Request) { const subject = text(form.get("subject")); const message = text(form.get("message")); - if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacy") !== "on") { + if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacyAccepted") !== "on") { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts index 5fb6c1b..81c771c 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -14,9 +14,9 @@ export async function POST(request: Request) { const model = form.get("model"); const deviceType = form.get("deviceType"); const description = form.get("description"); - const privacy = form.get("privacy"); + const privacyAccepted = form.get("privacyAccepted"); - if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacy !== "on") { + if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacyAccepted !== "on") { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index 12082e6..4fe05c0 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useRef, useState, type FormEvent } from "react"; export default function ContactForm() { + const formRef = useRef(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [pending, setPending] = useState(false); @@ -16,8 +17,9 @@ export default function ContactForm() { const email = String(form.get("email") ?? "").trim(); const subject = String(form.get("subject") ?? "").trim(); const text = String(form.get("message") ?? "").trim(); + const privacyAccepted = form.get("privacyAccepted") === "on"; - if (!name || !email.includes("@") || !subject || text.length < 10 || form.get("privacy") !== "on") { + if (!name || !email.includes("@") || !subject || text.length < 10 || !privacyAccepted) { setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise."); return; } @@ -27,17 +29,17 @@ export default function ContactForm() { const response = await fetch("/api/contact", { method: "POST", body: form }); const result = await response.json() as { message?: string }; if (!response.ok) throw new Error(result.message ?? "Nachricht konnte nicht gesendet werden."); - event.currentTarget.reset(); + formRef.current?.reset(); setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet."); } catch (err) { - setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden."); + setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut."); } finally { setPending(false); } } return ( -
+
@@ -59,7 +61,7 @@ export default function ContactForm() {