From 839bbff5950ae0a8c515546d854df3831b7a80d4 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 25 Sep 2026 04:36:36 +0200 Subject: [PATCH] feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv, Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg, obwohl die zugehoerige E-Mail nie rausging. - lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen Codes und festen Zielen weiter. - fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher, dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach einem Fehler weiter). - sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden"). - Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs. - SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist bis zur Antwort gegen Doppelklick gesperrt. - 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit "Deaktivieren". Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/actions/_shared.ts | 5 +- src/app/admin/actions/archive.ts | 8 +- src/app/admin/actions/content.ts | 4 +- src/app/admin/actions/customers.ts | 47 ++++++----- src/app/admin/actions/dhl.ts | 20 ++--- src/app/admin/actions/discount-codes.ts | 18 ++--- src/app/admin/actions/harvests-batches.ts | 26 +++---- src/app/admin/actions/inquiries.ts | 7 +- src/app/admin/actions/orders.ts | 44 +++++++---- src/app/admin/actions/products.ts | 26 +++---- src/app/admin/actions/reviews.ts | 3 + src/app/admin/actions/shipping.ts | 12 +-- src/app/admin/benutzer/[id]/page.tsx | 7 -- src/app/admin/benutzer/neu/page.tsx | 6 +- src/app/admin/benutzer/page.tsx | 8 +- src/app/admin/bestellungen/[id]/page.tsx | 7 -- src/app/admin/bestellungen/page.tsx | 8 +- src/app/admin/chargen/page.tsx | 6 +- src/app/admin/ernten/page.tsx | 4 - src/app/admin/gutscheine/[id]/page.tsx | 4 - src/app/admin/gutscheine/neu/page.tsx | 6 +- src/app/admin/gutscheine/page.tsx | 6 +- src/app/admin/inhalte/page.tsx | 9 +-- src/app/admin/layout.tsx | 8 +- src/app/admin/produkte/[id]/page.tsx | 7 -- src/app/admin/produkte/neu/page.tsx | 9 +-- src/app/admin/retouren/actions.ts | 29 ++++--- src/app/admin/versand/[id]/page.tsx | 4 - src/app/admin/versand/neu/page.tsx | 6 +- src/app/admin/versand/page.tsx | 6 +- src/app/api/auth/entra/callback/route.ts | 5 +- src/app/api/auth/entra/login/route.ts | 9 ++- src/app/api/hinweis/route.ts | 17 ++++ src/app/api/konto/email-bestaetigen/route.ts | 10 ++- src/app/globals.css | 34 ++++++++ src/app/honig/[slug]/page.tsx | 13 ---- src/app/honig/actions.ts | 39 ++++------ src/app/konto/actions/_shared.ts | 6 +- src/app/konto/actions/account.ts | 16 ++-- src/app/konto/actions/addresses.ts | 14 ++-- src/app/konto/actions/login.ts | 27 +++---- src/app/konto/actions/newsletter.ts | 4 +- src/app/konto/actions/password-reset.ts | 26 +++---- src/app/konto/actions/registration.ts | 40 +++++++--- src/app/konto/actions/two-factor.ts | 23 +++--- src/app/konto/adressen/page.tsx | 6 +- .../konto/bestellungen/[id]/retoure/page.tsx | 4 - src/app/konto/bestellungen/actions.ts | 19 ++--- src/app/konto/bestellungen/page.tsx | 9 +-- src/app/konto/page.tsx | 23 +----- src/app/konto/passwort-vergessen/page.tsx | 5 +- src/app/konto/passwort-zuruecksetzen/page.tsx | 5 +- src/app/konto/zwei-faktor-code/page.tsx | 6 +- src/app/konto/zwei-faktor/einrichten/page.tsx | 8 +- src/app/konto/zwei-faktor/page.tsx | 9 +-- src/app/layout.tsx | 7 +- src/app/rechnung/[invoiceNumber]/page.tsx | 3 +- src/components/confirm-form.tsx | 7 +- src/components/flash-toast.tsx | 78 +++++++++++++++++++ src/components/submit-feedback.tsx | 67 ++++++++++++++++ src/i18n/dictionaries/de.ts | 6 ++ src/i18n/dictionaries/en.ts | 6 ++ src/i18n/dictionary-type.ts | 6 ++ src/lib/email-verification.ts | 4 +- src/lib/flash-core.ts | 61 +++++++++++++++ src/lib/flash.test.ts | 61 +++++++++++++++ src/lib/flash.ts | 70 +++++++++++++++++ src/lib/mail.ts | 12 ++- src/lib/notice.ts | 33 ++++++++ src/lib/password-reset.ts | 4 +- src/proxy.ts | 16 ++-- 71 files changed, 762 insertions(+), 416 deletions(-) create mode 100644 src/app/api/hinweis/route.ts create mode 100644 src/components/flash-toast.tsx create mode 100644 src/components/submit-feedback.tsx create mode 100644 src/lib/flash-core.ts create mode 100644 src/lib/flash.test.ts create mode 100644 src/lib/flash.ts create mode 100644 src/lib/notice.ts diff --git a/src/app/admin/actions/_shared.ts b/src/app/admin/actions/_shared.ts index 3d4a297..5bbc56a 100644 --- a/src/app/admin/actions/_shared.ts +++ b/src/app/admin/actions/_shared.ts @@ -1,4 +1,3 @@ -import { redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { getCurrentAdmin } from "@/lib/auth"; import type { CustomerRole } from "@/generated/prisma/client"; @@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role: return admin; } -export function fail(path: string, message: string): never { - redirect(`${path}?error=${encodeURIComponent(message)}`); -} +export { fail, succeed } from "@/lib/flash"; /** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */ export async function isLastAdmin(customerId: string): Promise { diff --git a/src/app/admin/actions/archive.ts b/src/app/admin/actions/archive.ts index cadd7af..b95b273 100644 --- a/src/app/admin/actions/archive.ts +++ b/src/app/admin/actions/archive.ts @@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import type { ArchiveKind } from "@/lib/archive"; -import { requireAdmin, str } from "./_shared"; +import { requireAdmin, str, fail } from "./_shared"; +import { setFlash } from "@/lib/flash"; const LIST_PATH: Record = { orders: "/admin/bestellungen", @@ -35,7 +36,7 @@ export async function setArchivedAction(formData: FormData): Promise { const kind = str(formData, "kind"); const id = str(formData, "id"); const archive = str(formData, "mode") === "archive"; - if (!isArchiveKind(kind) || !id) return; + if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage."); const data = archive ? { archivedAt: new Date(), archivedManually: true } @@ -51,7 +52,7 @@ export async function setArchivedAction(formData: FormData): Promise { : kind === "returns" ? await prisma.returnRequest.updateMany({ where, data }) : await prisma.product.updateMany({ where, data }); - if (result.count === 0) return; + if (result.count === 0) return fail(LIST_PATH[kind], "Eintrag nicht gefunden."); await logAdminAction(admin, { action: archive ? "archive.manual_archive" : "archive.manual_restore", @@ -60,4 +61,5 @@ export async function setArchivedAction(formData: FormData): Promise { }); revalidatePath(LIST_PATH[kind]); + await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt."); } diff --git a/src/app/admin/actions/content.ts b/src/app/admin/actions/content.ts index 158a25a..a44ed6e 100644 --- a/src/app/admin/actions/content.ts +++ b/src/app/admin/actions/content.ts @@ -1,12 +1,12 @@ "use server"; -import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { CONTENT_REGISTRY } from "@/lib/content-registry"; import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads"; import { requireAdmin } from "./_shared"; +import { succeed } from "@/lib/flash"; /** * Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte) @@ -70,5 +70,5 @@ export async function updateSiteContentAction(formData: FormData): Promise }); revalidatePath("/", "layout"); - redirect("/admin/inhalte?saved=1"); + return succeed("/admin/inhalte", "Inhalte gespeichert."); } diff --git a/src/app/admin/actions/customers.ts b/src/app/admin/actions/customers.ts index 09fd02e..96adb03 100644 --- a/src/app/admin/actions/customers.ts +++ b/src/app/admin/actions/customers.ts @@ -1,6 +1,5 @@ "use server"; -import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/prisma"; @@ -10,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification"; import { logAdminAction } from "@/lib/audit-log"; import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation"; import { requireAdmin, fail, isLastAdmin, str } from "./_shared"; +import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; export async function createCustomerAction(formData: FormData): Promise { const admin = await requireAdmin(); @@ -21,12 +21,12 @@ export async function createCustomerAction(formData: FormData): Promise { role: str(formData, "role") || "KUNDE", }); if (!parsed.success) { - fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + return fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); } const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); if (existing) { - fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto."); + return fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto."); } // Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen, @@ -43,7 +43,7 @@ export async function createCustomerAction(formData: FormData): Promise { }, }); - await createAndSendPasswordResetEmail(customer); + const mailSent = await createAndSendPasswordResetEmail(customer); await logAdminAction(admin, { action: "customer.create", entityType: "Customer", @@ -52,7 +52,13 @@ export async function createCustomerAction(formData: FormData): Promise { }); revalidatePath("/admin/benutzer"); - redirect("/admin/benutzer"); + if (!mailSent) { + return fail( + "/admin/benutzer", + `Konto ${customer.email} wurde angelegt, aber die Einladung zum Passwort-Setzen ging nicht raus. ${MAIL_FAILED_HINT} Danach auf der Benutzerseite erneut senden.` + ); + } + return succeed("/admin/benutzer", `Konto ${customer.email} wurde angelegt — die Einladung zum Passwort-Setzen ist verschickt.`); } export async function updateCustomerAction(formData: FormData): Promise { @@ -67,23 +73,23 @@ export async function updateCustomerAction(formData: FormData): Promise { role: str(formData, "role") || "KUNDE", }); if (!parsed.success) { - fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + return fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); } if (id === admin.id && parsed.data.role !== admin.role) { - fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern."); + return fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern."); } const target = await prisma.customer.findUnique({ where: { id } }); - if (!target) fail("/admin/benutzer", "Konto nicht gefunden."); + if (!target) return fail("/admin/benutzer", "Konto nicht gefunden."); if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) { - fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden."); + return fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden."); } const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); if (emailOwner && emailOwner.id !== id) { - fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto."); + return fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto."); } const emailChanged = parsed.data.email !== target.email; @@ -116,7 +122,7 @@ export async function updateCustomerAction(formData: FormData): Promise { }); revalidatePath("/admin/benutzer"); - redirect("/admin/benutzer"); + return succeed("/admin/benutzer", "Änderungen gespeichert."); } export async function blockCustomerAction(formData: FormData): Promise { @@ -124,10 +130,10 @@ export async function blockCustomerAction(formData: FormData): Promise { const id = str(formData, "id"); if (id === admin.id) { - fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren."); + return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren."); } if (await isLastAdmin(id)) { - fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden."); + return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden."); } // sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der @@ -139,6 +145,7 @@ export async function blockCustomerAction(formData: FormData): Promise { }); await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email }); revalidatePath("/admin/benutzer"); + await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`); } export async function unblockCustomerAction(formData: FormData): Promise { @@ -156,6 +163,7 @@ export async function unblockCustomerAction(formData: FormData): Promise { detail: unblocked.email, }); revalidatePath("/admin/benutzer"); + await setFlash("success", `${unblocked.email} wurde entsperrt.`); } export async function deleteCustomerAction(formData: FormData): Promise { @@ -163,16 +171,16 @@ export async function deleteCustomerAction(formData: FormData): Promise { const id = str(formData, "id"); if (id === admin.id) { - fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen."); + return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen."); } if (await isLastAdmin(id)) { - fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden."); + return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden."); } const deleted = await prisma.customer.delete({ where: { id } }); await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email }); revalidatePath("/admin/benutzer"); - redirect("/admin/benutzer"); + return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`); } /** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */ @@ -181,14 +189,15 @@ export async function sendCustomerPasswordResetAction(formData: FormData): Promi const id = str(formData, "id"); const customer = await prisma.customer.findUnique({ where: { id } }); - if (!customer) fail("/admin/benutzer", "Konto nicht gefunden."); + if (!customer) return fail("/admin/benutzer", "Konto nicht gefunden."); - await createAndSendPasswordResetEmail(customer); + const mailSent = await createAndSendPasswordResetEmail(customer); await logAdminAction(admin, { action: "customer.password_reset_sent", entityType: "Customer", entityId: id, detail: customer.email, }); - redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt.")); + if (!mailSent) return fail(`/admin/benutzer/${id}`, MAIL_FAILED_HINT); + return succeed(`/admin/benutzer/${id}`, "Passwort-Reset-E-Mail wurde verschickt."); } diff --git a/src/app/admin/actions/dhl.ts b/src/app/admin/actions/dhl.ts index 7e85579..0b700a8 100644 --- a/src/app/admin/actions/dhl.ts +++ b/src/app/admin/actions/dhl.ts @@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log"; import { getCompanyInfo } from "@/lib/company-info"; import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl"; import { requireAdmin, fail, str } from "./_shared"; +import { setFlash } from "@/lib/flash"; // Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe // Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format, @@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise { }, }); if (!order) { - fail("/admin/bestellungen", "Bestellung nicht gefunden."); + return fail("/admin/bestellungen", "Bestellung nicht gefunden."); } if (order.fulfillmentMethod !== "VERSAND") { - fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig."); + return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig."); } if (order.payment?.status !== "SUCCEEDED") { - fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden."); + return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden."); } if (order.shipment?.trackingNumber) { - fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst."); + return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst."); } // Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige @@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise { claimed = false; } if (!claimed) { - fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade."); + return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade."); } const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0); @@ -126,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise { .catch(() => {}); if (error instanceof DhlNotConfiguredError) { - fail(detailPath, error.message); + return fail(detailPath, error.message); } if (error instanceof DhlApiError) { - fail(detailPath, error.message); + return fail(detailPath, error.message); } const message = error instanceof Error ? error.message : "Unbekannter Fehler"; - fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`); + return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`); } try { @@ -153,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise { `createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`, error ); - fail( + return fail( detailPath, "Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen." ); @@ -168,4 +169,5 @@ export async function createDhlLabelAction(formData: FormData): Promise { revalidatePath("/admin/bestellungen"); revalidatePath(detailPath); + await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`); } diff --git a/src/app/admin/actions/discount-codes.ts b/src/app/admin/actions/discount-codes.ts index 88c5353..a8e16c1 100644 --- a/src/app/admin/actions/discount-codes.ts +++ b/src/app/admin/actions/discount-codes.ts @@ -1,11 +1,11 @@ "use server"; -import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { discountCodeSchema } from "@/lib/validation"; import { requireAdmin, fail, str } from "./_shared"; +import { succeed } from "@/lib/flash"; function parseDiscountCodeForm(formData: FormData) { const rawMax = str(formData, "maxUses"); @@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise { @@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise { @@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise { revalidatePath("/admin/ernten"); revalidatePath("/ernten"); - redirect("/admin/ernten"); + return succeed("/admin/ernten", "Ernte angelegt."); } export async function updateHarvestAction(formData: FormData): Promise { @@ -80,7 +80,7 @@ export async function updateHarvestAction(formData: FormData): Promise { revalidatePath("/admin/ernten"); revalidatePath("/ernten"); - redirect("/admin/ernten"); + return succeed("/admin/ernten", "Ernte gespeichert."); } /** @@ -99,10 +99,10 @@ export async function deleteHarvestAction(formData: FormData): Promise { where: { id }, include: { _count: { select: { batches: true, products: true } } }, }); - if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden."); + if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden."); if (harvest._count.batches > 0 || harvest._count.products > 0) { - fail( + return fail( "/admin/ernten", `Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.` ); @@ -119,7 +119,7 @@ export async function deleteHarvestAction(formData: FormData): Promise { revalidatePath("/admin/ernten"); revalidatePath("/ernten"); - redirect("/admin/ernten"); + return succeed("/admin/ernten", "Ernte gelöscht."); } /** @@ -148,7 +148,7 @@ export async function toggleHarvestPublishedAction(formData: FormData): Promise< revalidatePath("/admin/ernten"); revalidatePath("/ernten"); - redirect("/admin/ernten"); + return succeed("/admin/ernten", "Veröffentlichungsstatus geändert."); } export async function createBatchAction(formData: FormData): Promise { @@ -169,7 +169,7 @@ export async function createBatchAction(formData: FormData): Promise { await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code }); revalidatePath("/admin/chargen"); - redirect("/admin/chargen"); + return succeed("/admin/chargen", "Charge angelegt."); } export async function updateBatchAction(formData: FormData): Promise { @@ -190,7 +190,7 @@ export async function updateBatchAction(formData: FormData): Promise { await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id }); revalidatePath("/admin/chargen"); - redirect("/admin/chargen"); + return succeed("/admin/chargen", "Charge gespeichert."); } /** @@ -210,16 +210,16 @@ export async function deleteBatchAction(formData: FormData): Promise { where: { id }, include: { _count: { select: { products: true, orderItems: true } } }, }); - if (!batch) fail("/admin/chargen", "Charge nicht gefunden."); + if (!batch) return fail("/admin/chargen", "Charge nicht gefunden."); if (batch._count.orderItems > 0) { - fail( + return fail( "/admin/chargen", `Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.` ); } if (batch._count.products > 0) { - fail( + return fail( "/admin/chargen", `Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.` ); @@ -230,5 +230,5 @@ export async function deleteBatchAction(formData: FormData): Promise { await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code }); revalidatePath("/admin/chargen"); - redirect("/admin/chargen"); + return succeed("/admin/chargen", "Charge gelöscht."); } diff --git a/src/app/admin/actions/inquiries.ts b/src/app/admin/actions/inquiries.ts index 0a41ce6..4ee7423 100644 --- a/src/app/admin/actions/inquiries.ts +++ b/src/app/admin/actions/inquiries.ts @@ -3,13 +3,14 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; -import { requireAdmin, str } from "./_shared"; +import { requireAdmin, str, fail } from "./_shared"; +import { setFlash } from "@/lib/flash"; export async function updateBusinessInquiryStatusAction(formData: FormData): Promise { const admin = await requireAdmin(); const id = str(formData, "id"); const status = str(formData, "status"); - if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return; + if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status."); await prisma.businessInquiry.update({ where: { id }, @@ -22,6 +23,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro entityId: id, detail: status, }); - revalidatePath("/admin/anfragen"); + await setFlash("success", "Status der Anfrage gespeichert."); } diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index f447b15..110d964 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { sendMail } from "@/lib/mail"; @@ -13,6 +12,7 @@ import { getStripeClient } from "@/lib/stripe"; import { isLocale, defaultLocale } from "@/i18n/config"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import { requireAdmin, str, strOrNull, fail } from "./_shared"; +import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; const ORDER_STATUSES = [ "NEU", @@ -124,7 +124,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise const status = str(formData, "status") as (typeof ORDER_STATUSES)[number]; const detailPath = `/admin/bestellungen/${orderId}`; if (!ORDER_STATUSES.includes(status)) { - fail(detailPath, "Ungültiger Status."); + return fail(detailPath, "Ungültiger Status."); } const result = await prisma.$transaction(async (tx) => { @@ -198,9 +198,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise }); if (!result) { - fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht."); + return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht."); } + // undefined = keine E-Mail faellig; false = faellig, aber nicht versendet. + let mailSent: boolean | undefined; + if (result) { await logAdminAction(admin, { action: "order.status_change", @@ -238,7 +241,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise // Ware ist bereits wieder im Bestand und die Absicht war eindeutig. // Aber der offene Rest muss auf dem Bildschirm landen und nicht nur // im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen. - fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`); + return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`); } } @@ -257,7 +260,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise trackingNumber, trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), }); - await sendMail({ to: result.email, subject, html, text }); + mailSent = await sendMail({ to: result.email, subject, html, text }); } if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") { const { subject, html, text } = await pickupReadyEmail({ @@ -265,11 +268,14 @@ export async function updateOrderStatusAction(formData: FormData): Promise orderNumber: result.orderNumber, firstName: result.firstName, }); - await sendMail({ to: result.email, subject, html, text }); + mailSent = await sendMail({ to: result.email, subject, html, text }); } } revalidatePath("/admin/bestellungen"); + const done = `Status von ${result.orderNumber} gespeichert.`; + if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`); + await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done); } /** @@ -324,7 +330,7 @@ export async function deleteOrderAction(formData: FormData): Promise { }, }); if (!order) { - fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht."); + return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht."); } const verdict = checkOrderDeletable({ @@ -333,7 +339,7 @@ export async function deleteOrderAction(formData: FormData): Promise { hasInvoice: order.invoice !== null, }); if (!verdict.deletable) { - fail(detailPath, { + return fail(detailPath, { invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`, payment: "Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.", @@ -351,7 +357,7 @@ export async function deleteOrderAction(formData: FormData): Promise { if (session.status === "open") { await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId); } else if (session.status === "complete") { - fail( + return fail( detailPath, "Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren." ); @@ -365,7 +371,7 @@ export async function deleteOrderAction(formData: FormData): Promise { `[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`, error ); - fail( + return fail( detailPath, "Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen." ); @@ -394,7 +400,7 @@ export async function deleteOrderAction(formData: FormData): Promise { }); if (!deleted) { - fail( + return fail( detailPath, "Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden." ); @@ -410,9 +416,7 @@ export async function deleteOrderAction(formData: FormData): Promise { }); revalidatePath("/admin/bestellungen"); - redirect( - `/admin/bestellungen?message=${encodeURIComponent(`Bestellung ${order.orderNumber} wurde gelöscht.`)}` - ); + return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`); } /** @@ -433,7 +437,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise< where: { id: orderId }, select: { orderNumber: true, email: true, firstName: true, status: true, locale: true }, }); - if (!order) return; + if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden."); await prisma.shipment.upsert({ where: { orderId }, @@ -448,6 +452,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise< detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(), }); + let mailSent: boolean | undefined; if (order.status === "VERSENDET" && carrier && trackingNumber) { const { subject, html, text } = await shipmentEmail({ locale: isLocale(order.locale) ? order.locale : defaultLocale, @@ -457,9 +462,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise< trackingNumber, trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), }); - await sendMail({ to: order.email, subject, html, text }); + mailSent = await sendMail({ to: order.email, subject, html, text }); } revalidatePath("/admin/bestellungen"); revalidatePath(`/admin/bestellungen/${orderId}`); + if (mailSent === false) { + return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`); + } + await setFlash( + "success", + mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert." + ); } diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index 5e53868..ff3c744 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -1,6 +1,5 @@ "use server"; -import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; @@ -8,6 +7,7 @@ import { notifyStockAvailable } from "@/lib/stock-notifications"; import { adminProductNumbersSchema } from "@/lib/validation"; import { isValidUploadPath } from "@/lib/uploads"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; +import { succeed } from "@/lib/flash"; /** * Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis @@ -18,9 +18,9 @@ import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; * Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses * Produkt bestellbar. */ -function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void { +async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise { if (isActive && priceCents <= 0) { - fail( + return fail( redirectPath, "Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern." ); @@ -42,12 +42,12 @@ export async function createProductAction(formData: FormData): Promise { vatRatePercent: str(formData, "vatRatePercent"), }); if (!parsedNumbers.success) { - fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); + return fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); } const numbers = parsedNumbers.data; const isActive = formData.get("isActive") === "on"; - assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents); + await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents); const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS"; // Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind @@ -64,7 +64,7 @@ export async function createProductAction(formData: FormData): Promise { // hervorgeht, dass lediglich die Adresse belegt ist. const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } }); if (slugTaken) { - fail( + return fail( "/admin/produkte/neu", `Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".` ); @@ -101,7 +101,7 @@ export async function createProductAction(formData: FormData): Promise { }); revalidatePath("/admin/produkte"); - redirect("/admin/produkte"); + return succeed("/admin/produkte", "Produkt angelegt."); } /** @@ -131,10 +131,10 @@ export async function deleteProductAction(formData: FormData): Promise { where: { id }, include: { _count: { select: { orderItems: true } } }, }); - if (!product) fail("/admin/produkte", "Produkt nicht gefunden."); + if (!product) return fail("/admin/produkte", "Produkt nicht gefunden."); if (product._count.orderItems > 0) { - fail( + return fail( "/admin/produkte", `"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.` ); @@ -150,7 +150,7 @@ export async function deleteProductAction(formData: FormData): Promise { }); revalidatePath("/admin/produkte"); - redirect("/admin/produkte"); + return succeed("/admin/produkte", "Produkt gelöscht."); } export async function updateProductAction(formData: FormData): Promise { @@ -168,12 +168,12 @@ export async function updateProductAction(formData: FormData): Promise { vatRatePercent: str(formData, "vatRatePercent"), }); if (!parsedNumbers.success) { - fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); + return fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); } const numbers = parsedNumbers.data; const isActive = formData.get("isActive") === "on"; - assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents); + await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents); const previousInventory = await prisma.inventory.findUnique({ where: { productId: id }, @@ -216,5 +216,5 @@ export async function updateProductAction(formData: FormData): Promise { } revalidatePath("/admin/produkte"); - redirect("/admin/produkte"); + return succeed("/admin/produkte", "Produkt gespeichert."); } diff --git a/src/app/admin/actions/reviews.ts b/src/app/admin/actions/reviews.ts index 49f7921..a476ee2 100644 --- a/src/app/admin/actions/reviews.ts +++ b/src/app/admin/actions/reviews.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { requireAdmin, str } from "./_shared"; +import { setFlash } from "@/lib/flash"; export async function approveReviewAction(formData: FormData): Promise { const admin = await requireAdmin(); @@ -24,6 +25,7 @@ export async function approveReviewAction(formData: FormData): Promise { revalidatePath("/admin/bewertungen"); revalidatePath(`/honig/${review.product.slug}`); + await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar."); } export async function deleteReviewAction(formData: FormData): Promise { @@ -44,4 +46,5 @@ export async function deleteReviewAction(formData: FormData): Promise { revalidatePath("/admin/bewertungen"); revalidatePath(`/honig/${review.product.slug}`); + await setFlash("success", "Bewertung gelöscht."); } diff --git a/src/app/admin/actions/shipping.ts b/src/app/admin/actions/shipping.ts index c62ce6e..95c7b65 100644 --- a/src/app/admin/actions/shipping.ts +++ b/src/app/admin/actions/shipping.ts @@ -1,11 +1,11 @@ "use server"; -import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { shippingTierSchema } from "@/lib/validation"; import { requireAdmin, fail, str } from "./_shared"; +import { succeed } from "@/lib/flash"; function parseShippingTierForm(formData: FormData) { const rawMax = str(formData, "maxQuantity"); @@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise { @@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise { @@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise) { const t = getDictionary(await getLocale()); const { id } = await props.params; - const { error, message } = await props.searchParams; const [admin, customer] = await Promise.all([ getCurrentAdmin(), @@ -36,12 +35,6 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben

{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}

- {error ? ( -

{error}

- ) : null} - {message ? ( -

{message}

- ) : null}
diff --git a/src/app/admin/benutzer/neu/page.tsx b/src/app/admin/benutzer/neu/page.tsx index 7de0b5e..5d66a8b 100644 --- a/src/app/admin/benutzer/neu/page.tsx +++ b/src/app/admin/benutzer/neu/page.tsx @@ -3,9 +3,8 @@ import { createCustomerAction } from "../../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; -export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) { +export default async function AdminBenutzerNewPage() { const t = getDictionary(await getLocale()); - const { error } = await props.searchParams; return (
@@ -13,9 +12,6 @@ export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benu

{t.adminUserNewPage.title}

{t.adminUserNewPage.intro}

- {error ? ( -

{error}

- ) : null}
diff --git a/src/app/admin/benutzer/page.tsx b/src/app/admin/benutzer/page.tsx index 0f58765..7e6d04b 100644 --- a/src/app/admin/benutzer/page.tsx +++ b/src/app/admin/benutzer/page.tsx @@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary"; export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) { const t = getDictionary(await getLocale()); const roleLabels: Record = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin }; - const { error, message, q } = await props.searchParams; + const { q } = await props.searchParams; const query = typeof q === "string" ? q.trim() : ""; const [admin, customers] = await Promise.all([ @@ -42,12 +42,6 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
- {error ? ( -

{error}

- ) : null} - {message ? ( -

{message}

- ) : null}