feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 04:36:36 +02:00
1 parent fb9c2353c6
commit 839bbff595
71 files changed
+757 -411

No files matched your search

+1 -4
View File
@@ -1,4 +1,3 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import type { CustomerRole } from "@/generated/prisma/client"; import type { CustomerRole } from "@/generated/prisma/client";
@@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role:
return admin; return admin;
} }
export function fail(path: string, message: string): never { export { fail, succeed } from "@/lib/flash";
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */ /** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
export async function isLastAdmin(customerId: string): Promise<boolean> { export async function isLastAdmin(customerId: string): Promise<boolean> {
+5 -3
View File
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import type { ArchiveKind } from "@/lib/archive"; 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<ArchiveKind, string> = { const LIST_PATH: Record<ArchiveKind, string> = {
orders: "/admin/bestellungen", orders: "/admin/bestellungen",
@@ -35,7 +36,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
const kind = str(formData, "kind"); const kind = str(formData, "kind");
const id = str(formData, "id"); const id = str(formData, "id");
const archive = str(formData, "mode") === "archive"; const archive = str(formData, "mode") === "archive";
if (!isArchiveKind(kind) || !id) return; if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage.");
const data = archive const data = archive
? { archivedAt: new Date(), archivedManually: true } ? { archivedAt: new Date(), archivedManually: true }
@@ -51,7 +52,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
: kind === "returns" : kind === "returns"
? await prisma.returnRequest.updateMany({ where, data }) ? await prisma.returnRequest.updateMany({ where, data })
: await prisma.product.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, { await logAdminAction(admin, {
action: archive ? "archive.manual_archive" : "archive.manual_restore", action: archive ? "archive.manual_archive" : "archive.manual_restore",
@@ -60,4 +61,5 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
}); });
revalidatePath(LIST_PATH[kind]); revalidatePath(LIST_PATH[kind]);
await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt.");
} }
+2 -2
View File
@@ -1,12 +1,12 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { CONTENT_REGISTRY } from "@/lib/content-registry"; import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads"; import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared"; import { requireAdmin } from "./_shared";
import { succeed } from "@/lib/flash";
/** /**
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte) * Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
@@ -70,5 +70,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
}); });
revalidatePath("/", "layout"); revalidatePath("/", "layout");
redirect("/admin/inhalte?saved=1"); return succeed("/admin/inhalte", "Inhalte gespeichert.");
} }
+28 -19
View File
@@ -1,6 +1,5 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -10,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation"; import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
import { requireAdmin, fail, isLastAdmin, str } from "./_shared"; import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
export async function createCustomerAction(formData: FormData): Promise<void> { export async function createCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -21,12 +21,12 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
role: str(formData, "role") || "KUNDE", role: str(formData, "role") || "KUNDE",
}); });
if (!parsed.success) { 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 } }); const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (existing) { 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, // Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
@@ -43,7 +43,7 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
}, },
}); });
await createAndSendPasswordResetEmail(customer); const mailSent = await createAndSendPasswordResetEmail(customer);
await logAdminAction(admin, { await logAdminAction(admin, {
action: "customer.create", action: "customer.create",
entityType: "Customer", entityType: "Customer",
@@ -52,7 +52,13 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
}); });
revalidatePath("/admin/benutzer"); 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<void> { export async function updateCustomerAction(formData: FormData): Promise<void> {
@@ -67,23 +73,23 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
role: str(formData, "role") || "KUNDE", role: str(formData, "role") || "KUNDE",
}); });
if (!parsed.success) { 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) { 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 } }); 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))) { 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 } }); const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (emailOwner && emailOwner.id !== id) { 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; const emailChanged = parsed.data.email !== target.email;
@@ -116,7 +122,7 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
}); });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
redirect("/admin/benutzer"); return succeed("/admin/benutzer", "Änderungen gespeichert.");
} }
export async function blockCustomerAction(formData: FormData): Promise<void> { export async function blockCustomerAction(formData: FormData): Promise<void> {
@@ -124,10 +130,10 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
const id = str(formData, "id"); const id = str(formData, "id");
if (id === admin.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)) { 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 // sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
@@ -139,6 +145,7 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
}); });
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email }); await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`);
} }
export async function unblockCustomerAction(formData: FormData): Promise<void> { export async function unblockCustomerAction(formData: FormData): Promise<void> {
@@ -156,6 +163,7 @@ export async function unblockCustomerAction(formData: FormData): Promise<void> {
detail: unblocked.email, detail: unblocked.email,
}); });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
await setFlash("success", `${unblocked.email} wurde entsperrt.`);
} }
export async function deleteCustomerAction(formData: FormData): Promise<void> { export async function deleteCustomerAction(formData: FormData): Promise<void> {
@@ -163,16 +171,16 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
const id = str(formData, "id"); const id = str(formData, "id");
if (id === admin.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)) { 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 } }); const deleted = await prisma.customer.delete({ where: { id } });
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email }); await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
revalidatePath("/admin/benutzer"); 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. */ /** 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 id = str(formData, "id");
const customer = await prisma.customer.findUnique({ where: { 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, { await logAdminAction(admin, {
action: "customer.password_reset_sent", action: "customer.password_reset_sent",
entityType: "Customer", entityType: "Customer",
entityId: id, entityId: id,
detail: customer.email, 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.");
} }
+11 -9
View File
@@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl"; import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
import { requireAdmin, fail, str } from "./_shared"; import { requireAdmin, fail, str } from "./_shared";
import { setFlash } from "@/lib/flash";
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe // Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format, // Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
@@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
}, },
}); });
if (!order) { if (!order) {
fail("/admin/bestellungen", "Bestellung nicht gefunden."); return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
} }
if (order.fulfillmentMethod !== "VERSAND") { 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") { 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) { 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 // Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
@@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
claimed = false; claimed = false;
} }
if (!claimed) { 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); const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
@@ -126,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
.catch(() => {}); .catch(() => {});
if (error instanceof DhlNotConfiguredError) { if (error instanceof DhlNotConfiguredError) {
fail(detailPath, error.message); return fail(detailPath, error.message);
} }
if (error instanceof DhlApiError) { if (error instanceof DhlApiError) {
fail(detailPath, error.message); return fail(detailPath, error.message);
} }
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; 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 { try {
@@ -153,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`, `createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
error error
); );
fail( return fail(
detailPath, detailPath,
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen." "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<void> {
revalidatePath("/admin/bestellungen"); revalidatePath("/admin/bestellungen");
revalidatePath(detailPath); revalidatePath(detailPath);
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
} }
+9 -9
View File
@@ -1,11 +1,11 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { discountCodeSchema } from "@/lib/validation"; import { discountCodeSchema } from "@/lib/validation";
import { requireAdmin, fail, str } from "./_shared"; import { requireAdmin, fail, str } from "./_shared";
import { succeed } from "@/lib/flash";
function parseDiscountCodeForm(formData: FormData) { function parseDiscountCodeForm(formData: FormData) {
const rawMax = str(formData, "maxUses"); const rawMax = str(formData, "maxUses");
@@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
const parsed = parseDiscountCodeForm(formData); const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } }); const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
if (existing) { if (existing) {
fail("/admin/gutscheine/neu", "Dieser Code existiert bereits."); return fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
} }
const code = await prisma.discountCode.create({ data: parsed.data }); const code = await prisma.discountCode.create({ data: parsed.data });
@@ -48,7 +48,7 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
}); });
revalidatePath("/admin/gutscheine"); revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine"); return succeed("/admin/gutscheine", "Gutschein angelegt.");
} }
export async function updateDiscountCodeAction(formData: FormData): Promise<void> { export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
@@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
const parsed = parseDiscountCodeForm(formData); const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } }); const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
if (existing && existing.id !== id) { if (existing && existing.id !== id) {
fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet."); return fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
} }
await prisma.discountCode.update({ where: { id }, data: parsed.data }); await prisma.discountCode.update({ where: { id }, data: parsed.data });
@@ -75,7 +75,7 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
}); });
revalidatePath("/admin/gutscheine"); revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine"); return succeed("/admin/gutscheine", "Gutschein gespeichert.");
} }
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> { export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
@@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
try { try {
deleted = await prisma.discountCode.delete({ where: { id } }); deleted = await prisma.discountCode.delete({ where: { id } });
} catch { } catch {
fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht."); return fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
} }
await logAdminAction(admin, { await logAdminAction(admin, {
@@ -100,5 +100,5 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
}); });
revalidatePath("/admin/gutscheine"); revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine"); return succeed("/admin/gutscheine", "Gutschein gelöscht.");
} }
+13 -13
View File
@@ -1,11 +1,11 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared"; import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
import { succeed } from "@/lib/flash";
/** /**
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel * Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
@@ -55,7 +55,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten"); revalidatePath("/ernten");
redirect("/admin/ernten"); return succeed("/admin/ernten", "Ernte angelegt.");
} }
export async function updateHarvestAction(formData: FormData): Promise<void> { export async function updateHarvestAction(formData: FormData): Promise<void> {
@@ -80,7 +80,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten"); revalidatePath("/ernten");
redirect("/admin/ernten"); return succeed("/admin/ernten", "Ernte gespeichert.");
} }
/** /**
@@ -99,10 +99,10 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
where: { id }, where: { id },
include: { _count: { select: { batches: true, products: true } } }, 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) { if (harvest._count.batches > 0 || harvest._count.products > 0) {
fail( return fail(
"/admin/ernten", "/admin/ernten",
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.` `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<void> {
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/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("/admin/ernten");
revalidatePath("/ernten"); revalidatePath("/ernten");
redirect("/admin/ernten"); return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
} }
export async function createBatchAction(formData: FormData): Promise<void> { export async function createBatchAction(formData: FormData): Promise<void> {
@@ -169,7 +169,7 @@ export async function createBatchAction(formData: FormData): Promise<void> {
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code }); await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
redirect("/admin/chargen"); return succeed("/admin/chargen", "Charge angelegt.");
} }
export async function updateBatchAction(formData: FormData): Promise<void> { export async function updateBatchAction(formData: FormData): Promise<void> {
@@ -190,7 +190,7 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id }); await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
redirect("/admin/chargen"); return succeed("/admin/chargen", "Charge gespeichert.");
} }
/** /**
@@ -210,16 +210,16 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
where: { id }, where: { id },
include: { _count: { select: { products: true, orderItems: true } } }, 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) { if (batch._count.orderItems > 0) {
fail( return fail(
"/admin/chargen", "/admin/chargen",
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.` `Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
); );
} }
if (batch._count.products > 0) { if (batch._count.products > 0) {
fail( return fail(
"/admin/chargen", "/admin/chargen",
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.` `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<void> {
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code }); await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
redirect("/admin/chargen"); return succeed("/admin/chargen", "Charge gelöscht.");
} }
+4 -3
View File
@@ -3,13 +3,14 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; 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<void> { export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
const status = str(formData, "status"); 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({ await prisma.businessInquiry.update({
where: { id }, where: { id },
@@ -22,6 +23,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro
entityId: id, entityId: id,
detail: status, detail: status,
}); });
revalidatePath("/admin/anfragen"); revalidatePath("/admin/anfragen");
await setFlash("success", "Status der Anfrage gespeichert.");
} }
+28 -16
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
@@ -13,6 +12,7 @@ import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
const ORDER_STATUSES = [ const ORDER_STATUSES = [
"NEU", "NEU",
@@ -124,7 +124,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number]; const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
const detailPath = `/admin/bestellungen/${orderId}`; const detailPath = `/admin/bestellungen/${orderId}`;
if (!ORDER_STATUSES.includes(status)) { if (!ORDER_STATUSES.includes(status)) {
fail(detailPath, "Ungültiger Status."); return fail(detailPath, "Ungültiger Status.");
} }
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
@@ -198,9 +198,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
}); });
if (!result) { 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) { if (result) {
await logAdminAction(admin, { await logAdminAction(admin, {
action: "order.status_change", action: "order.status_change",
@@ -238,7 +241,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig. // Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur // Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen. // 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<void>
trackingNumber, trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, 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") { if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
const { subject, html, text } = await pickupReadyEmail({ const { subject, html, text } = await pickupReadyEmail({
@@ -265,11 +268,14 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
orderNumber: result.orderNumber, orderNumber: result.orderNumber,
firstName: result.firstName, firstName: result.firstName,
}); });
await sendMail({ to: result.email, subject, html, text }); mailSent = await sendMail({ to: result.email, subject, html, text });
} }
} }
revalidatePath("/admin/bestellungen"); 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<void> {
}, },
}); });
if (!order) { 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({ const verdict = checkOrderDeletable({
@@ -333,7 +339,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
hasInvoice: order.invoice !== null, hasInvoice: order.invoice !== null,
}); });
if (!verdict.deletable) { 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.`, 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: payment:
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.", "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<void> {
if (session.status === "open") { if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId); await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
} else if (session.status === "complete") { } else if (session.status === "complete") {
fail( return fail(
detailPath, detailPath,
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren." "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<void> {
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`, `[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
error error
); );
fail( return fail(
detailPath, detailPath,
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen." "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<void> {
}); });
if (!deleted) { if (!deleted) {
fail( return fail(
detailPath, detailPath,
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden." "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<void> {
}); });
revalidatePath("/admin/bestellungen"); revalidatePath("/admin/bestellungen");
redirect( return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
`/admin/bestellungen?message=${encodeURIComponent(`Bestellung ${order.orderNumber} wurde gelöscht.`)}`
);
} }
/** /**
@@ -433,7 +437,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
where: { id: orderId }, where: { id: orderId },
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true }, 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({ await prisma.shipment.upsert({
where: { orderId }, where: { orderId },
@@ -448,6 +452,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(), detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
}); });
let mailSent: boolean | undefined;
if (order.status === "VERSENDET" && carrier && trackingNumber) { if (order.status === "VERSENDET" && carrier && trackingNumber) {
const { subject, html, text } = await shipmentEmail({ const { subject, html, text } = await shipmentEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale, locale: isLocale(order.locale) ? order.locale : defaultLocale,
@@ -457,9 +462,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
trackingNumber, trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, 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");
revalidatePath(`/admin/bestellungen/${orderId}`); 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."
);
} }
+13 -13
View File
@@ -1,6 +1,5 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
@@ -8,6 +7,7 @@ import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductNumbersSchema } from "@/lib/validation"; import { adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash";
/** /**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis * 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 * Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
* Produkt bestellbar. * Produkt bestellbar.
*/ */
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void { async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise<void> {
if (isActive && priceCents <= 0) { if (isActive && priceCents <= 0) {
fail( return fail(
redirectPath, redirectPath,
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern." "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<void> {
vatRatePercent: str(formData, "vatRatePercent"), vatRatePercent: str(formData, "vatRatePercent"),
}); });
if (!parsedNumbers.success) { 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 numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on"; 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"; const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind // Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
@@ -64,7 +64,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
// hervorgeht, dass lediglich die Adresse belegt ist. // hervorgeht, dass lediglich die Adresse belegt ist.
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } }); const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
if (slugTaken) { if (slugTaken) {
fail( return fail(
"/admin/produkte/neu", "/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()}".` `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<void> {
}); });
revalidatePath("/admin/produkte"); revalidatePath("/admin/produkte");
redirect("/admin/produkte"); return succeed("/admin/produkte", "Produkt angelegt.");
} }
/** /**
@@ -131,10 +131,10 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
where: { id }, where: { id },
include: { _count: { select: { orderItems: true } } }, 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) { if (product._count.orderItems > 0) {
fail( return fail(
"/admin/produkte", "/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.` `"${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<void> {
}); });
revalidatePath("/admin/produkte"); revalidatePath("/admin/produkte");
redirect("/admin/produkte"); return succeed("/admin/produkte", "Produkt gelöscht.");
} }
export async function updateProductAction(formData: FormData): Promise<void> { export async function updateProductAction(formData: FormData): Promise<void> {
@@ -168,12 +168,12 @@ export async function updateProductAction(formData: FormData): Promise<void> {
vatRatePercent: str(formData, "vatRatePercent"), vatRatePercent: str(formData, "vatRatePercent"),
}); });
if (!parsedNumbers.success) { 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 numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on"; 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({ const previousInventory = await prisma.inventory.findUnique({
where: { productId: id }, where: { productId: id },
@@ -216,5 +216,5 @@ export async function updateProductAction(formData: FormData): Promise<void> {
} }
revalidatePath("/admin/produkte"); revalidatePath("/admin/produkte");
redirect("/admin/produkte"); return succeed("/admin/produkte", "Produkt gespeichert.");
} }
+3
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str } from "./_shared"; import { requireAdmin, str } from "./_shared";
import { setFlash } from "@/lib/flash";
export async function approveReviewAction(formData: FormData): Promise<void> { export async function approveReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -24,6 +25,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bewertungen"); revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`); revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
} }
export async function deleteReviewAction(formData: FormData): Promise<void> { export async function deleteReviewAction(formData: FormData): Promise<void> {
@@ -44,4 +46,5 @@ export async function deleteReviewAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bewertungen"); revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`); revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung gelöscht.");
} }
+6 -6
View File
@@ -1,11 +1,11 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { shippingTierSchema } from "@/lib/validation"; import { shippingTierSchema } from "@/lib/validation";
import { requireAdmin, fail, str } from "./_shared"; import { requireAdmin, fail, str } from "./_shared";
import { succeed } from "@/lib/flash";
function parseShippingTierForm(formData: FormData) { function parseShippingTierForm(formData: FormData) {
const rawMax = str(formData, "maxQuantity"); const rawMax = str(formData, "maxQuantity");
@@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
const parsed = parseShippingTierForm(formData); const parsed = parseShippingTierForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const tier = await prisma.shippingTier.create({ const tier = await prisma.shippingTier.create({
@@ -42,7 +42,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand"); revalidatePath("/admin/versand");
revalidatePath("/warenkorb"); revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung"); revalidatePath("/versand-zahlung");
redirect("/admin/versand"); return succeed("/admin/versand", "Versandstufe angelegt.");
} }
export async function updateShippingTierAction(formData: FormData): Promise<void> { export async function updateShippingTierAction(formData: FormData): Promise<void> {
@@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
const parsed = parseShippingTierForm(formData); const parsed = parseShippingTierForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
await prisma.shippingTier.update({ await prisma.shippingTier.update({
@@ -68,7 +68,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand"); revalidatePath("/admin/versand");
revalidatePath("/warenkorb"); revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung"); revalidatePath("/versand-zahlung");
redirect("/admin/versand"); return succeed("/admin/versand", "Versandstufe gespeichert.");
} }
export async function deleteShippingTierAction(formData: FormData): Promise<void> { export async function deleteShippingTierAction(formData: FormData): Promise<void> {
@@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand"); revalidatePath("/admin/versand");
revalidatePath("/warenkorb"); revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung"); revalidatePath("/versand-zahlung");
redirect("/admin/versand"); return succeed("/admin/versand", "Versandstufe gelöscht.");
} }
-7
View File
@@ -17,7 +17,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) { export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { id } = await props.params; const { id } = await props.params;
const { error, message } = await props.searchParams;
const [admin, customer] = await Promise.all([ const [admin, customer] = await Promise.all([
getCurrentAdmin(), getCurrentAdmin(),
@@ -36,12 +35,6 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
</h1> </h1>
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p> <p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
+1 -5
View File
@@ -3,9 +3,8 @@ import { createCustomerAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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 t = getDictionary(await getLocale());
const { error } = await props.searchParams;
return ( return (
<div className="max-w-xl"> <div className="max-w-xl">
@@ -13,9 +12,6 @@ export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benu
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1> <h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p> <p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
+1 -7
View File
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) { export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin }; const roleLabels: Record<string, string> = { 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 query = typeof q === "string" ? q.trim() : "";
const [admin, customers] = await Promise.all([ const [admin, customers] = await Promise.all([
@@ -42,12 +42,6 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
</Link> </Link>
</div> </div>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm"> <label className="text-sm">
-7
View File
@@ -19,7 +19,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const { id } = await props.params; const { id } = await props.params;
const { error, message } = await props.searchParams;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -47,12 +46,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<div className="max-w-3xl"> <div className="max-w-3xl">
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} /> <BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<div className="flex flex-wrap items-start justify-between gap-4 mb-8"> <div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div> <div>
+1 -7
View File
@@ -15,7 +15,7 @@ import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const { q, status, error, message, ansicht } = await props.searchParams; const { q, status, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
const isArchive = isArchiveView(ansicht); const isArchive = isArchiveView(ansicht);
@@ -53,12 +53,6 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung {/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */} aus wie ein folgenloser Klick. */}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<AdminArchiveTabs <AdminArchiveTabs
basePath="/admin/bestellungen" basePath="/admin/bestellungen"
+1 -5
View File
@@ -7,9 +7,8 @@ import { createBatchAction, deleteBatchAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBatchesPage(props: PageProps<"/admin/chargen">) { export default async function AdminBatchesPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [batches, harvests] = await Promise.all([ const [batches, harvests] = await Promise.all([
prisma.batch.findMany({ prisma.batch.findMany({
orderBy: { fillDate: "desc" }, orderBy: { fillDate: "desc" },
@@ -22,9 +21,6 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
<div> <div>
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} /> <BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
-4
View File
@@ -11,7 +11,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) { export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const error = searchParams.error;
const harvests = await prisma.harvest.findMany({ const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }], orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } }, include: { _count: { select: { batches: true, products: true } } },
@@ -22,9 +21,6 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<ApiCoreSyncResult searchParams={searchParams} /> <ApiCoreSyncResult searchParams={searchParams} />
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
-4
View File
@@ -13,7 +13,6 @@ function toDateInputValue(date: Date | null): string {
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) { export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { id } = await props.params; const { id } = await props.params;
const { error } = await props.searchParams;
const code = await prisma.discountCode.findUnique({ where: { id } }); const code = await prisma.discountCode.findUnique({ where: { id } });
if (!code) notFound(); if (!code) notFound();
@@ -22,9 +21,6 @@ export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gu
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} /> <BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={code.id} /> <input type="hidden" name="id" value={code.id} />
+1 -5
View File
@@ -3,18 +3,14 @@ import { createDiscountCodeAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinNewPage(props: PageProps<"/admin/gutscheine/neu">) { export default async function AdminGutscheinNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
return ( return (
<div className="max-w-md"> <div className="max-w-md">
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} /> <BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required /> <Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
+1 -5
View File
@@ -7,9 +7,8 @@ import { deleteDiscountCodeAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) { export default async function AdminGutscheinePage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } }); const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
return ( return (
@@ -22,9 +21,6 @@ export default async function AdminGutscheinePage(props: PageProps<"/admin/gutsc
</Link> </Link>
</div> </div>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
+1 -8
View File
@@ -197,10 +197,9 @@ function GroupForm({
); );
} }
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) { export default async function AdminInhaltePage() {
const locale = await getLocale(); const locale = await getLocale();
const t = getDictionary(locale); const t = getDictionary(locale);
const { saved } = await props.searchParams;
const current = await loadCurrentValues(); const current = await loadCurrentValues();
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [ const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
@@ -218,12 +217,6 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1> <h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p> <p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
{saved ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminContentPage.saved}
</p>
) : null}
<section className="mb-12"> <section className="mb-12">
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2> <h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p> <p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
+3 -5
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { noticeUrl } from "@/lib/notice";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { logoutAction } from "@/app/konto/actions"; import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell"; import { AdminShell } from "@/components/admin-shell";
@@ -30,7 +31,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt). // entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
const admin = await getCurrentAdmin(); const admin = await getCurrentAdmin();
if (!admin) { if (!admin) {
redirect("/konto?error=" + encodeURIComponent("Bitte melde dich mit einem Admin-Konto an.")); redirect(noticeUrl("admin-login"));
} }
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf // Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
@@ -38,10 +39,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung // gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann. // statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
if (!admin.twoFactorEnabled) { if (!admin.twoFactorEnabled) {
redirect( redirect(noticeUrl("admin-2fa"));
"/konto/zwei-faktor?error=" +
encodeURIComponent("Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.")
);
} }
return ( return (
-7
View File
@@ -10,7 +10,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) { export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { id } = await props.params; const { id } = await props.params;
const { error } = await props.searchParams;
const rawProduct = await prisma.product.findUnique({ const rawProduct = await prisma.product.findUnique({
where: { id }, where: { id },
include: { harvest: true, batch: true, inventory: true }, include: { harvest: true, batch: true, inventory: true },
@@ -36,12 +35,6 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
) )
: null} : null}
</p> </p>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem Formular und wusste nicht, was abgelehnt wurde. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={product.id} /> <input type="hidden" name="id" value={product.id} />
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required /> <Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
+1 -8
View File
@@ -5,9 +5,8 @@ import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage(props: PageProps<"/admin/produkte/neu">) { export default async function AdminProductNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [harvests, batches] = await Promise.all([ const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }), prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }), prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
@@ -17,12 +16,6 @@ export default async function AdminProductNewPage(props: PageProps<"/admin/produ
<div className="max-w-xl"> <div className="max-w-xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} /> <BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem leeren Formular und wusste nicht, was fehlte. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="name" label={t.adminProductNewPage.nameLabel} required /> <Field name="name" label={t.adminProductNewPage.nameLabel} required />
<Field name="slug" label={t.adminProductNewPage.slugLabel} /> <Field name="slug" label={t.adminProductNewPage.slugLabel} />
+18 -11
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { fail, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/app/admin/actions"; import { requireAdmin } from "@/app/admin/actions";
@@ -15,15 +15,12 @@ function str(formData: FormData, key: string): string {
return String(formData.get(key) ?? "").trim(); return String(formData.get(key) ?? "").trim();
} }
function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> { export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
const status = str(formData, "status"); const status = str(formData, "status");
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return; if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return fail("/admin/retouren", "Ungültiger Status.");
const returnRequest = await prisma.returnRequest.findUnique({ const returnRequest = await prisma.returnRequest.findUnique({
where: { id }, where: { id },
@@ -31,7 +28,9 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
}); });
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits // Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird. // erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") return; if (!returnRequest || returnRequest.status !== "ANGEFRAGT") {
return fail("/admin/retouren", "Diese Retoure wurde bereits entschieden oder existiert nicht mehr.");
}
const updated = await prisma.returnRequest.update({ const updated = await prisma.returnRequest.update({
where: { id }, where: { id },
@@ -51,9 +50,12 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
status === "ANGENOMMEN" status === "ANGENOMMEN"
? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName }) ? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName })
: await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName }); : await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName });
await sendMail({ to: updated.order.email, subject, html, text }); const mailSent = await sendMail({ to: updated.order.email, subject, html, text });
revalidatePath("/admin/retouren"); revalidatePath("/admin/retouren");
const done = `Retoure ${updated.order.orderNumber} ${status === "ANGENOMMEN" ? "angenommen" : "abgelehnt"}.`;
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
} }
export async function refundReturnRequestAction(formData: FormData): Promise<void> { export async function refundReturnRequestAction(formData: FormData): Promise<void> {
@@ -68,11 +70,13 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
}, },
}, },
}); });
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return; if (!returnRequest || returnRequest.status !== "ANGENOMMEN") {
return fail("/admin/retouren", "Diese Retoure kann nicht (mehr) erstattet werden.");
}
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId; const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
if (!paymentIntentId) { if (!paymentIntentId) {
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten."); return fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
} }
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf // Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
@@ -87,7 +91,7 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
where: { id, status: "ANGENOMMEN" }, where: { id, status: "ANGENOMMEN" },
data: { status: "ERSTATTET" }, data: { status: "ERSTATTET" },
}); });
if (claimed.count === 0) return; if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
const stripe = getStripeClient(); const stripe = getStripeClient();
try { try {
@@ -116,7 +120,10 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
orderNumber: returnRequest.order.orderNumber, orderNumber: returnRequest.order.orderNumber,
firstName: returnRequest.order.firstName, firstName: returnRequest.order.firstName,
}); });
await sendMail({ to: returnRequest.order.email, subject, html, text }); const mailSent = await sendMail({ to: returnRequest.order.email, subject, html, text });
revalidatePath("/admin/retouren"); revalidatePath("/admin/retouren");
const done = `Retoure ${returnRequest.order.orderNumber} erstattet — der Betrag geht über Stripe zurück.`;
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
} }
-4
View File
@@ -8,7 +8,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) { export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { id } = await props.params; const { id } = await props.params;
const { error } = await props.searchParams;
const tier = await prisma.shippingTier.findUnique({ where: { id } }); const tier = await prisma.shippingTier.findUnique({ where: { id } });
if (!tier) notFound(); if (!tier) notFound();
@@ -17,9 +16,6 @@ export default async function AdminVersandEditPage(props: PageProps<"/admin/vers
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} /> <BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={tier.id} /> <input type="hidden" name="id" value={tier.id} />
+1 -5
View File
@@ -3,18 +3,14 @@ import { createShippingTierAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandNewPage(props: PageProps<"/admin/versand/neu">) { export default async function AdminVersandNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
return ( return (
<div className="max-w-md"> <div className="max-w-md">
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} /> <BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
+1 -5
View File
@@ -7,9 +7,8 @@ import { deleteShippingTierAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandPage(props: PageProps<"/admin/versand">) { export default async function AdminVersandPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } }); const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
return ( return (
@@ -24,9 +23,6 @@ export default async function AdminVersandPage(props: PageProps<"/admin/versand"
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p> <p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
+2 -3
View File
@@ -1,5 +1,6 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withFlash } from "@/lib/flash";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { import {
@@ -50,9 +51,7 @@ export async function GET(request: Request) {
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
function redirectWithError(message: string): NextResponse { function redirectWithError(message: string): NextResponse {
const url = new URL("/konto", baseUrl); return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message);
url.searchParams.set("error", message);
return NextResponse.redirect(url);
} }
if (!isEntraConfigured()) { if (!isEntraConfigured()) {
+6 -3
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withFlash } from "@/lib/flash";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { setEntraOAuthCookie } from "@/lib/auth"; import { setEntraOAuthCookie } from "@/lib/auth";
import { signEntraOAuthState } from "@/lib/session"; import { signEntraOAuthState } from "@/lib/session";
@@ -12,9 +13,11 @@ import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOA
*/ */
export async function GET() { export async function GET() {
if (!isEntraConfigured()) { if (!isEntraConfigured()) {
const url = new URL("/konto", getBaseUrl()); return withFlash(
url.searchParams.set("error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."); NextResponse.redirect(new URL("/konto", getBaseUrl())),
return NextResponse.redirect(url); "error",
"SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."
);
} }
const { codeVerifier, codeChallenge } = generatePkcePair(); const { codeVerifier, codeChallenge } = generatePkcePair();
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { withFlash } from "@/lib/flash-core";
import { NOTICES, isNoticeCode } from "@/lib/notice";
import { getBaseUrl } from "@/lib/url";
/**
* Zwischenstation fuer Weiterleitungen aus Server Components (Layouts,
* Seiten): haengt die Flash-Meldung zu einem festen Code an und leitet auf
* dessen festes Ziel weiter (siehe lib/notice.ts).
*/
export async function GET(request: Request) {
const code = new URL(request.url).searchParams.get("code");
const baseUrl = getBaseUrl();
if (!isNoticeCode(code)) return NextResponse.redirect(new URL("/", baseUrl));
const notice = NOTICES[code];
return withFlash(NextResponse.redirect(new URL(notice.to, baseUrl)), "error", await notice.message());
}
+6 -4
View File
@@ -2,12 +2,12 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { hashVerificationToken } from "@/lib/email-verification"; import { hashVerificationToken } from "@/lib/email-verification";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { withFlash } from "@/lib/flash";
export async function GET(request: Request) { export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token"); const token = new URL(request.url).searchParams.get("token");
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
const fail = (message: string) => const fail = (message: string) => withFlash(NextResponse.redirect(`${baseUrl}/konto`), "error", message);
NextResponse.redirect(`${baseUrl}/konto?error=${encodeURIComponent(message)}`);
if (!token) { if (!token) {
return fail("Dieser Bestätigungslink ist ungültig."); return fail("Dieser Bestätigungslink ist ungültig.");
@@ -34,7 +34,9 @@ export async function GET(request: Request) {
}), }),
]); ]);
return NextResponse.redirect( return withFlash(
`${baseUrl}/konto?message=${encodeURIComponent("Deine E-Mail-Adresse wurde bestätigt.")}` NextResponse.redirect(`${baseUrl}/konto`),
"success",
"Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen."
); );
} }
+34
View File
@@ -69,3 +69,37 @@ p {
background: var(--color-honey); background: var(--color-honey);
color: var(--color-cream); color: var(--color-cream);
} }
/* Knopf eines gerade laufenden Server-Action-Formulars (components/
submit-feedback.tsx): sichtbar "arbeitet", gegen Doppelklick gesperrt. */
[data-pending="true"] {
cursor: progress;
opacity: 0.6;
}
[data-pending="true"]::after {
content: "";
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-left: 0.5em;
vertical-align: -0.1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 9999px;
animation: wh-spin 0.7s linear infinite;
}
@keyframes wh-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
[data-pending="true"]::after {
animation: none;
border-right-color: currentColor;
opacity: 0.5;
}
}
-13
View File
@@ -81,7 +81,6 @@ export default async function ProductDetailPage(
const locale = await getLocale(); const locale = await getLocale();
const t = getDictionary(locale); const t = getDictionary(locale);
const { slug } = await props.params; const { slug } = await props.params;
const { error, message, notifyError, notifyMessage } = await props.searchParams;
const product = await getProductBySlug(slug); const product = await getProductBySlug(slug);
if (!product) notFound(); if (!product) notFound();
@@ -349,12 +348,6 @@ export default async function ProductDetailPage(
</div> </div>
) : null} ) : null}
</form> </form>
{notifyError ? (
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
) : null}
{notifyMessage ? (
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
) : null}
</> </>
) : null} ) : null}
</> </>
@@ -438,12 +431,6 @@ export default async function ProductDetailPage(
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2> <h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
</ScrollReveal> </ScrollReveal>
{error ? (
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mt-4 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{reviews.length === 0 ? ( {reviews.length === 0 ? (
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p> <p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
+14 -25
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { fail, succeed } from "@/lib/flash";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
@@ -16,9 +17,6 @@ const REVIEW_WINDOW_MS = 60 * 60 * 1000;
const STOCK_NOTIFY_ATTEMPTS_PER_WINDOW = 5; const STOCK_NOTIFY_ATTEMPTS_PER_WINDOW = 5;
const STOCK_NOTIFY_WINDOW_MS = 60 * 60 * 1000; const STOCK_NOTIFY_WINDOW_MS = 60 * 60 * 1000;
function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
// redirectPath kommt aus einem Formularfeld (siehe toggleWishlistAction) und // redirectPath kommt aus einem Formularfeld (siehe toggleWishlistAction) und
// darf deshalb nicht ungeprueft an redirect()/revalidatePath() durchgereicht // darf deshalb nicht ungeprueft an redirect()/revalidatePath() durchgereicht
@@ -31,11 +29,11 @@ function isSafeRedirectPath(path: string): boolean {
export async function submitReviewAction(formData: FormData): Promise<void> { export async function submitReviewAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an, um eine Bewertung abzugeben."); if (!customer) return fail("/konto", "Bitte melde dich an, um eine Bewertung abzugeben.");
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`review:${ip}`, REVIEW_ATTEMPTS_PER_WINDOW, REVIEW_WINDOW_MS)) { if (!rateLimit(`review:${ip}`, REVIEW_ATTEMPTS_PER_WINDOW, REVIEW_WINDOW_MS)) {
fail("/shop", "Zu viele Bewertungen. Bitte versuche es später erneut."); return fail("/shop", "Zu viele Bewertungen. Bitte versuche es später erneut.");
} }
const parsed = reviewSchema.safeParse({ const parsed = reviewSchema.safeParse({
@@ -44,14 +42,14 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
comment: formData.get("comment"), comment: formData.get("comment"),
}); });
if (!parsed.success) { if (!parsed.success) {
fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const product = await prisma.product.findUnique({ const product = await prisma.product.findUnique({
where: { id: parsed.data.productId }, where: { id: parsed.data.productId },
select: { slug: true }, select: { slug: true },
}); });
if (!product) fail("/shop", "Produkt nicht gefunden."); if (!product) return fail("/shop", "Produkt nicht gefunden.");
const redirectPath = `/honig/${product.slug}`; const redirectPath = `/honig/${product.slug}`;
@@ -59,12 +57,12 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
// bewerten ("verifizierter Kauf") — verhindert beliebige Fremdbewertungen. // bewerten ("verifizierter Kauf") — verhindert beliebige Fremdbewertungen.
const purchased = await hasCustomerPurchased(parsed.data.productId, customer.id); const purchased = await hasCustomerPurchased(parsed.data.productId, customer.id);
if (!purchased) { if (!purchased) {
fail(redirectPath, "Nur Käufer:innen dieses Produkts können eine Bewertung abgeben."); return fail(redirectPath, "Nur Käufer:innen dieses Produkts können eine Bewertung abgeben.");
} }
const existing = await getCustomerReview(parsed.data.productId, customer.id); const existing = await getCustomerReview(parsed.data.productId, customer.id);
if (existing) { if (existing) {
fail(redirectPath, "Du hast dieses Produkt bereits bewertet."); return fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
} }
await prisma.review.create({ await prisma.review.create({
@@ -77,13 +75,13 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
}); });
revalidatePath(redirectPath); revalidatePath(redirectPath);
redirect(`${redirectPath}?message=${encodeURIComponent("Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht.")}`); return succeed(redirectPath, "Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht.");
} }
/** Fuegt ein Produkt der Wunschliste hinzu bzw. entfernt es, je nach aktuellem Zustand. */ /** Fuegt ein Produkt der Wunschliste hinzu bzw. entfernt es, je nach aktuellem Zustand. */
export async function toggleWishlistAction(formData: FormData): Promise<void> { export async function toggleWishlistAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an, um Produkte zu merken."); if (!customer) return fail("/konto", "Bitte melde dich an, um Produkte zu merken.");
const productId = String(formData.get("productId") ?? ""); const productId = String(formData.get("productId") ?? "");
const submittedPath = String(formData.get("redirectPath") ?? "/shop"); const submittedPath = String(formData.get("redirectPath") ?? "/shop");
@@ -114,13 +112,6 @@ export async function toggleWishlistAction(formData: FormData): Promise<void> {
redirect(redirectPath); redirect(redirectPath);
} }
// Eigene Query-Parameter statt der von submitReviewAction genutzten
// error/message: beide Actions redirecten auf dieselbe Produktseite, ein
// gemeinsamer Parametername wuerde sonst z.B. eine Bewertungsbestaetigung
// faelschlich auch im Ausverkauft-Block der Seite anzeigen lassen.
function failNotify(path: string, message: string): never {
redirect(`${path}?notifyError=${encodeURIComponent(message)}`);
}
/** /**
* Meldet eine E-Mail-Adresse fuer die Wiederverfuegbarkeits-Benachrichtigung * Meldet eine E-Mail-Adresse fuer die Wiederverfuegbarkeits-Benachrichtigung
@@ -130,14 +121,14 @@ function failNotify(path: string, message: string): never {
export async function subscribeStockNotificationAction(formData: FormData): Promise<void> { export async function subscribeStockNotificationAction(formData: FormData): Promise<void> {
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`stock-notify:${ip}`, STOCK_NOTIFY_ATTEMPTS_PER_WINDOW, STOCK_NOTIFY_WINDOW_MS)) { if (!rateLimit(`stock-notify:${ip}`, STOCK_NOTIFY_ATTEMPTS_PER_WINDOW, STOCK_NOTIFY_WINDOW_MS)) {
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut."); return fail("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
} }
// Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter // Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter
// Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber // Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber
// unseren Absender anzuschreiben. // unseren Absender anzuschreiben.
if (!(await isTurnstileValid(formData, ip))) { if (!(await isTurnstileValid(formData, ip))) {
failNotify("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); return fail("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
} }
const parsed = stockNotificationSchema.safeParse({ const parsed = stockNotificationSchema.safeParse({
@@ -145,14 +136,14 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
email: formData.get("email"), email: formData.get("email"),
}); });
if (!parsed.success) { if (!parsed.success) {
failNotify("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const product = await prisma.product.findUnique({ const product = await prisma.product.findUnique({
where: { id: parsed.data.productId }, where: { id: parsed.data.productId },
select: { slug: true }, select: { slug: true },
}); });
if (!product) failNotify("/shop", "Produkt nicht gefunden."); if (!product) return fail("/shop", "Produkt nicht gefunden.");
const redirectPath = `/honig/${product.slug}`; const redirectPath = `/honig/${product.slug}`;
@@ -165,7 +156,5 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
update: { notifiedAt: null, locale }, update: { notifiedAt: null, locale },
}); });
redirect( return succeed(redirectPath, "Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.");
`${redirectPath}?notifyMessage=${encodeURIComponent("Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.")}`
);
} }
+1 -5
View File
@@ -1,8 +1,4 @@
import { redirect } from "next/navigation"; export { fail, succeed } from "@/lib/flash";
export function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA // Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene // deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
+8 -8
View File
@@ -1,6 +1,5 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth"; import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { rateLimit, getClientIp } from "@/lib/rate-limit";
@@ -8,6 +7,7 @@ import { sendMail } from "@/lib/mail";
import { securityAlertEmail } from "@/lib/email-templates"; import { securityAlertEmail } from "@/lib/email-templates";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared"; import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
import { succeed } from "@/lib/flash";
/** /**
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus * Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
@@ -19,10 +19,10 @@ import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CO
*/ */
export async function deleteOwnAccountAction(formData: FormData): Promise<void> { export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
if (customer.role === "ADMIN") { if (customer.role === "ADMIN") {
fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin."); return fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
} }
const ip = await getClientIp(); const ip = await getClientIp();
@@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
) )
) { ) {
fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut."); return fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
} }
const password = String(formData.get("password") ?? ""); const password = String(formData.get("password") ?? "");
const passwordValid = await verifyPassword(password, customer.passwordHash); const passwordValid = await verifyPassword(password, customer.passwordHash);
if (!passwordValid) { if (!passwordValid) {
fail("/konto", "Passwort ist falsch."); return fail("/konto", "Passwort ist falsch.");
} }
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted"); const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
@@ -47,7 +47,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
await prisma.customer.delete({ where: { id: customer.id } }); await prisma.customer.delete({ where: { id: customer.id } });
await destroySession(); await destroySession();
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht.")); return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
} }
/** /**
@@ -59,7 +59,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
*/ */
export async function logoutAllDevicesAction(): Promise<void> { export async function logoutAllDevicesAction(): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
await prisma.customer.update({ await prisma.customer.update({
where: { id: customer.id }, where: { id: customer.id },
@@ -67,5 +67,5 @@ export async function logoutAllDevicesAction(): Promise<void> {
}); });
await destroySession(); await destroySession();
redirect("/konto?message=" + encodeURIComponent("Alle Geräte wurden abgemeldet. Bitte melde dich erneut an.")); return succeed("/konto", "Alle Geräte wurden abgemeldet. Bitte melde dich erneut an.");
} }
+9 -5
View File
@@ -5,10 +5,11 @@ import { prisma } from "@/lib/prisma";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
import { addressSchema } from "@/lib/validation"; import { addressSchema } from "@/lib/validation";
import { fail } from "./_shared"; import { fail } from "./_shared";
import { setFlash } from "@/lib/flash";
export async function addAddressAction(formData: FormData): Promise<void> { export async function addAddressAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
const parsed = addressSchema.safeParse({ const parsed = addressSchema.safeParse({
label: formData.get("label"), label: formData.get("label"),
@@ -21,7 +22,7 @@ export async function addAddressAction(formData: FormData): Promise<void> {
isDefault: formData.get("isDefault") === "on", isDefault: formData.get("isDefault") === "on",
}); });
if (!parsed.success) { if (!parsed.success) {
fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
if (parsed.data.isDefault) { if (parsed.data.isDefault) {
@@ -30,26 +31,29 @@ export async function addAddressAction(formData: FormData): Promise<void> {
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } }); await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
revalidatePath("/konto/adressen"); revalidatePath("/konto/adressen");
await setFlash("success", "Adresse gespeichert.");
} }
export async function deleteAddressAction(formData: FormData): Promise<void> { export async function deleteAddressAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
const addressId = String(formData.get("addressId") ?? ""); const addressId = String(formData.get("addressId") ?? "");
await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } }); await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } });
revalidatePath("/konto/adressen"); revalidatePath("/konto/adressen");
await setFlash("success", "Adresse gelöscht.");
} }
export async function setDefaultAddressAction(formData: FormData): Promise<void> { export async function setDefaultAddressAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
const addressId = String(formData.get("addressId") ?? ""); const addressId = String(formData.get("addressId") ?? "");
const address = await prisma.address.findFirst({ where: { id: addressId, customerId: customer.id } }); const address = await prisma.address.findFirst({ where: { id: addressId, customerId: customer.id } });
if (!address) fail("/konto/adressen", "Adresse nicht gefunden."); if (!address) return fail("/konto/adressen", "Adresse nicht gefunden.");
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } }); await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
await prisma.address.update({ where: { id: address.id }, data: { isDefault: true } }); await prisma.address.update({ where: { id: address.id }, data: { isDefault: true } });
revalidatePath("/konto/adressen"); revalidatePath("/konto/adressen");
await setFlash("success", "Standardadresse geändert.");
} }
+14 -13
View File
@@ -10,6 +10,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit";
import type { Customer } from "@/generated/prisma/client"; import type { Customer } from "@/generated/prisma/client";
import { fail } from "./_shared"; import { fail } from "./_shared";
import { isTurnstileValid } from "@/lib/turnstile"; import { isTurnstileValid } from "@/lib/turnstile";
import { succeed } from "@/lib/flash";
const LOGIN_ATTEMPTS_PER_WINDOW = 10; const LOGIN_ATTEMPTS_PER_WINDOW = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000; const LOGIN_WINDOW_MS = 15 * 60 * 1000;
@@ -37,13 +38,13 @@ async function registerFailedLogin(customer: Pick<Customer, "id" | "failedLoginA
export async function loginAction(formData: FormData): Promise<void> { export async function loginAction(formData: FormData): Promise<void> {
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) { if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut."); return fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
} }
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab, // CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten. // bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) { if (!(await isTurnstileValid(formData, ip))) {
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
} }
const parsed = loginSchema.safeParse({ const parsed = loginSchema.safeParse({
@@ -51,23 +52,23 @@ export async function loginAction(formData: FormData): Promise<void> {
password: formData.get("password"), password: formData.get("password"),
}); });
if (!parsed.success) { if (!parsed.success) {
fail("/konto", "Bitte E-Mail und Passwort angeben."); return fail("/konto", "Bitte E-Mail und Passwort angeben.");
} }
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte // Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen. // Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) { if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) {
fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut."); return fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
} }
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (customer?.blockedAt) { if (customer?.blockedAt) {
fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns."); return fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
} }
if (customer?.lockedUntil && customer.lockedUntil > new Date()) { if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
fail( return fail(
"/konto", "/konto",
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück." "Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
); );
@@ -79,7 +80,7 @@ export async function loginAction(formData: FormData): Promise<void> {
if (!customer || !passwordValid) { if (!customer || !passwordValid) {
if (customer) await registerFailedLogin(customer); if (customer) await registerFailedLogin(customer);
fail("/konto", "E-Mail-Adresse oder Passwort ist falsch."); return fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
} }
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) { if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
@@ -102,24 +103,24 @@ export async function loginAction(formData: FormData): Promise<void> {
export async function verifyTwoFactorAction(formData: FormData): Promise<void> { export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
const customerId = await getTwoFactorChallengeCustomerId(); const customerId = await getTwoFactorChallengeCustomerId();
if (!customerId) { if (!customerId) {
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an."); return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
} }
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`2fa-verify:${ip}:${customerId}`, TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW, TWO_FACTOR_VERIFY_WINDOW_MS)) { if (!rateLimit(`2fa-verify:${ip}:${customerId}`, TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW, TWO_FACTOR_VERIFY_WINDOW_MS)) {
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut."); return fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
} }
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab, // CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten. // bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) { if (!(await isTurnstileValid(formData, ip))) {
fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); return fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
} }
const code = String(formData.get("code") ?? "").trim(); const code = String(formData.get("code") ?? "").trim();
const customer = await prisma.customer.findUnique({ where: { id: customerId } }); const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) { if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an."); return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
} }
const totpStep = const totpStep =
@@ -152,7 +153,7 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
} }
if (!authenticated) { if (!authenticated) {
fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen."); return fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
} }
await clearTwoFactorChallengeCookie(); await clearTwoFactorChallengeCookie();
@@ -162,5 +163,5 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
export async function logoutAction(): Promise<void> { export async function logoutAction(): Promise<void> {
await destroySession(); await destroySession();
redirect("/konto"); return succeed("/konto", "Du wurdest abgemeldet.");
} }
+3 -1
View File
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
import { fail } from "./_shared"; import { fail } from "./_shared";
import { setFlash } from "@/lib/flash";
export async function toggleNewsletterAction(): Promise<void> { export async function toggleNewsletterAction(): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }); const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } });
@@ -28,4 +29,5 @@ export async function toggleNewsletterAction(): Promise<void> {
} }
revalidatePath("/konto"); revalidatePath("/konto");
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
} }
+12 -12
View File
@@ -14,6 +14,7 @@ import { securityAlertEmail } from "@/lib/email-templates";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { fail } from "./_shared"; import { fail } from "./_shared";
import { isTurnstileValid } from "@/lib/turnstile"; import { isTurnstileValid } from "@/lib/turnstile";
import { succeed } from "@/lib/flash";
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5; const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000; const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
@@ -26,18 +27,18 @@ const MIN_RESET_REQUEST_RESPONSE_MS = 400;
export async function requestPasswordResetAction(formData: FormData): Promise<void> { export async function requestPasswordResetAction(formData: FormData): Promise<void> {
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) { if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) {
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut."); return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
} }
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab, // CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten. // bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) { if (!(await isTurnstileValid(formData, ip))) {
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
} }
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") }); const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
if (!parsed.success) { if (!parsed.success) {
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein."); return fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
} }
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
@@ -57,13 +58,13 @@ export async function requestPasswordResetAction(formData: FormData): Promise<vo
export async function resetPasswordAction(formData: FormData): Promise<void> { export async function resetPasswordAction(formData: FormData): Promise<void> {
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) { if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) {
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut."); return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
} }
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab, // CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten. // bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) { if (!(await isTurnstileValid(formData, ip))) {
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
} }
const parsed = resetPasswordSchema.safeParse({ const parsed = resetPasswordSchema.safeParse({
@@ -71,7 +72,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
password: formData.get("password"), password: formData.get("password"),
}); });
if (!parsed.success) { if (!parsed.success) {
fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const resetToken = await prisma.passwordResetToken.findUnique({ const resetToken = await prisma.passwordResetToken.findUnique({
@@ -80,7 +81,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date(); const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
if (!isValid) { if (!isValid) {
fail( return fail(
"/konto/passwort-vergessen", "/konto/passwort-vergessen",
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an." "Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
); );
@@ -92,7 +93,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
// handlungsfaehig machen — sonst waere die Sperre per Reset-Link // handlungsfaehig machen — sonst waere die Sperre per Reset-Link
// eigenstaendig umgehbar. // eigenstaendig umgehbar.
if (targetCustomer?.blockedAt) { if (targetCustomer?.blockedAt) {
fail( return fail(
"/konto/passwort-vergessen", "/konto/passwort-vergessen",
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an." "Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
); );
@@ -103,10 +104,9 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
// Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes // Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes
// Passwort versuchen kann, statt einen komplett neuen Reset-Link // Passwort versuchen kann, statt einen komplett neuen Reset-Link
// anfordern zu muessen. // anfordern zu muessen.
redirect( return fail(
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}&error=${encodeURIComponent( `/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}`,
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort." "Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
)}`
); );
} }
@@ -147,5 +147,5 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
} }
await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion); await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion);
redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert.")); return succeed("/konto", "Dein Passwort wurde geändert.");
} }
+28 -12
View File
@@ -9,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
import { verifyTurnstileToken } from "@/lib/turnstile"; import { verifyTurnstileToken } from "@/lib/turnstile";
import { isPasswordBreached } from "@/lib/password-breach-check"; import { isPasswordBreached } from "@/lib/password-breach-check";
import { fail } from "./_shared"; import { fail } from "./_shared";
import { succeed } from "@/lib/flash";
const REGISTER_ATTEMPTS_PER_WINDOW = 5; const REGISTER_ATTEMPTS_PER_WINDOW = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000; const REGISTER_WINDOW_MS = 60 * 60 * 1000;
@@ -18,12 +19,12 @@ const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
export async function registerAction(formData: FormData): Promise<void> { export async function registerAction(formData: FormData): Promise<void> {
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) { if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut."); return fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
} }
const turnstileToken = String(formData.get("turnstileToken") ?? ""); const turnstileToken = String(formData.get("turnstileToken") ?? "");
if (!(await verifyTurnstileToken(turnstileToken, ip))) { if (!(await verifyTurnstileToken(turnstileToken, ip))) {
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
} }
const parsed = registerSchema.safeParse({ const parsed = registerSchema.safeParse({
@@ -33,15 +34,15 @@ export async function registerAction(formData: FormData): Promise<void> {
password: formData.get("password"), password: formData.get("password"),
}); });
if (!parsed.success) { if (!parsed.success) {
fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
if (formData.get("password") !== formData.get("passwordConfirm")) { if (formData.get("password") !== formData.get("passwordConfirm")) {
fail("/konto", "Die Passwörter stimmen nicht überein."); return fail("/konto", "Die Passwörter stimmen nicht überein.");
} }
if (await isPasswordBreached(parsed.data.password)) { if (await isPasswordBreached(parsed.data.password)) {
fail( return fail(
"/konto", "/konto",
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort." "Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
); );
@@ -54,7 +55,7 @@ export async function registerAction(formData: FormData): Promise<void> {
// beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind // beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts. // (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
await hashPassword(parsed.data.password); await hashPassword(parsed.data.password);
fail( return fail(
"/konto", "/konto",
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail." "Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
); );
@@ -72,22 +73,37 @@ export async function registerAction(formData: FormData): Promise<void> {
}, },
}); });
await createAndSendVerificationEmail(customer); const mailSent = await createAndSendVerificationEmail(customer);
await createSession(customer.id, customer.role, customer.sessionVersion); await createSession(customer.id, customer.role, customer.sessionVersion);
redirect("/konto"); if (!mailSent) {
return fail(
"/konto",
"Konto erstellt — aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte nutze gleich „Link erneut senden“."
);
}
return succeed(
"/konto",
`Willkommen! Bitte bestätige deine E-Mail-Adresse über den Link, den wir an ${customer.email} geschickt haben.`
);
} }
/** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */ /** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */
export async function resendVerificationEmailAction(): Promise<void> { export async function resendVerificationEmailAction(): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
if (customer.emailVerifiedAt) redirect("/konto"); if (customer.emailVerifiedAt) redirect("/konto");
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) { if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) {
fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut."); return fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
} }
await createAndSendVerificationEmail(customer); const mailSent = await createAndSendVerificationEmail(customer);
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet.")); if (!mailSent) {
return fail(
"/konto",
"Die E-Mail konnte gerade nicht versendet werden. Bitte versuche es in ein paar Minuten erneut oder schreib uns an [email protected]."
);
}
return succeed("/konto", `Wir haben dir einen neuen Bestätigungslink an ${customer.email} geschickt.`);
} }
+13 -10
View File
@@ -19,13 +19,14 @@ import { sendMail } from "@/lib/mail";
import { securityAlertEmail } from "@/lib/email-templates"; import { securityAlertEmail } from "@/lib/email-templates";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared"; import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
import { succeed } from "@/lib/flash";
const TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW = 8; const TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
const TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS = 10 * 60 * 1000; const TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS = 10 * 60 * 1000;
export async function startTwoFactorSetupAction(): Promise<void> { export async function startTwoFactorSetupAction(): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
if (customer.twoFactorEnabled) redirect("/konto/zwei-faktor"); if (customer.twoFactorEnabled) redirect("/konto/zwei-faktor");
const secret = generateTotpSecret(); const secret = generateTotpSecret();
@@ -36,11 +37,11 @@ export async function startTwoFactorSetupAction(): Promise<void> {
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> { export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
const setup = await getTwoFactorSetup(); const setup = await getTwoFactorSetup();
if (!setup || setup.customerId !== customer.id) { if (!setup || setup.customerId !== customer.id) {
fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut."); return fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
} }
const ip = await getClientIp(); const ip = await getClientIp();
@@ -51,12 +52,12 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS
) )
) { ) {
fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut."); return fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
} }
const code = String(formData.get("code") ?? "").trim(); const code = String(formData.get("code") ?? "").trim();
if (!verifyTotpCode(setup.secret, code, customer.email)) { if (!verifyTotpCode(setup.secret, code, customer.email)) {
fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut."); return fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
} }
const { plain, hashes } = generateBackupCodes(); const { plain, hashes } = generateBackupCodes();
@@ -83,12 +84,14 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
export async function dismissBackupCodesAction(): Promise<void> { export async function dismissBackupCodesAction(): Promise<void> {
await clearTwoFactorBackupCodesDisplayCookie(); await clearTwoFactorBackupCodesDisplayCookie();
redirect("/konto/zwei-faktor"); // Zurueck ins Profil statt auf die 2FA-Seite: dort steht als einzige
// Aktion "deaktivieren" — direkt nach dem Einrichten die falsche Einladung.
return succeed("/konto", "Zwei-Faktor-Authentifizierung ist jetzt aktiv. Dein Konto ist zusätzlich geschützt.");
} }
export async function disableTwoFactorAction(formData: FormData): Promise<void> { export async function disableTwoFactorAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) return fail("/konto", "Bitte melde dich an.");
const ip = await getClientIp(); const ip = await getClientIp();
if ( if (
@@ -98,13 +101,13 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
) )
) { ) {
fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut."); return fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
} }
const password = String(formData.get("password") ?? ""); const password = String(formData.get("password") ?? "");
const passwordValid = await verifyPassword(password, customer.passwordHash); const passwordValid = await verifyPassword(password, customer.passwordHash);
if (!passwordValid) { if (!passwordValid) {
fail("/konto/zwei-faktor", "Passwort ist falsch."); return fail("/konto/zwei-faktor", "Passwort ist falsch.");
} }
await prisma.$transaction([ await prisma.$transaction([
@@ -121,5 +124,5 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled"); const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled");
await sendMail({ to: updated.email, subject, html, text }); await sendMail({ to: updated.email, subject, html, text });
redirect("/konto/zwei-faktor?message=" + encodeURIComponent("Zwei-Faktor-Authentifizierung wurde deaktiviert.")); return succeed("/konto", "Zwei-Faktor-Authentifizierung wurde deaktiviert.");
} }
+1 -5
View File
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Meine Adressen" }; export const metadata: Metadata = { title: "Meine Adressen" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function KontoAdressenPage(props: PageProps<"/konto/adressen">) { export default async function KontoAdressenPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect("/konto"); if (!customer) redirect("/konto");
@@ -27,9 +26,6 @@ export default async function KontoAdressenPage(props: PageProps<"/konto/adresse
<BackLink href="/konto" label={t.ordersPage.backToAccount} /> <BackLink href="/konto" label={t.ordersPage.backToAccount} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} /> <SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="mt-10 space-y-4"> <div className="mt-10 space-y-4">
{addresses.map((address) => ( {addresses.map((address) => (
@@ -21,7 +21,6 @@ const RETURN_ELIGIBLE_STATUSES = [
export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) { export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { id } = await props.params; const { id } = await props.params;
const { error } = await props.searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect("/konto"); if (!customer) redirect("/konto");
@@ -55,9 +54,6 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
))} ))}
</ul> </ul>
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={requestReturnAction} className="mt-6 space-y-4"> <form action={requestReturnAction} className="mt-6 space-y-4">
<input type="hidden" name="orderId" value={order.id} /> <input type="hidden" name="orderId" value={order.id} />
+7 -12
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { fail, succeed } from "@/lib/flash";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
@@ -17,13 +17,10 @@ const RETURN_ELIGIBLE_STATUSES = [
"ABGEHOLT", "ABGEHOLT",
]; ];
function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
export async function requestReturnAction(formData: FormData): Promise<void> { export async function requestReturnAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen."); if (!customer) return fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
const parsed = returnRequestSchema.safeParse({ const parsed = returnRequestSchema.safeParse({
orderId: formData.get("orderId"), orderId: formData.get("orderId"),
@@ -31,7 +28,7 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
}); });
if (!parsed.success) { if (!parsed.success) {
const orderId = String(formData.get("orderId") ?? ""); const orderId = String(formData.get("orderId") ?? "");
fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
@@ -40,13 +37,13 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
}); });
if (!order || order.customerId !== customer.id) { if (!order || order.customerId !== customer.id) {
fail("/konto/bestellungen", "Bestellung nicht gefunden."); return fail("/konto/bestellungen", "Bestellung nicht gefunden.");
} }
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status)) { if (!RETURN_ELIGIBLE_STATUSES.includes(order.status)) {
fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden."); return fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
} }
if (order.returnRequest) { if (order.returnRequest) {
fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor."); return fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
} }
await prisma.returnRequest.create({ await prisma.returnRequest.create({
@@ -54,7 +51,5 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
}); });
revalidatePath("/konto/bestellungen"); revalidatePath("/konto/bestellungen");
redirect( return succeed("/konto/bestellungen", "Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.");
`/konto/bestellungen?message=${encodeURIComponent("Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.")}`
);
} }
+1 -8
View File
@@ -25,12 +25,11 @@ const RETURN_ELIGIBLE_STATUSES = [
"ABGEHOLT", "ABGEHOLT",
]; ];
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) { export default async function KontoBestellungenPage() {
const locale = await getLocale(); const locale = await getLocale();
const t = getDictionary(locale); const t = getDictionary(locale);
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const returnStatusLabels = t.ordersPage.returnStatusLabels; const returnStatusLabels = t.ordersPage.returnStatusLabels;
const { error, message } = await props.searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect("/konto"); if (!customer) redirect("/konto");
@@ -42,12 +41,6 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
<BackLink href="/konto" label={t.ordersPage.backToAccount} /> <BackLink href="/konto" label={t.ordersPage.backToAccount} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} /> <SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{orders.length === 0 ? ( {orders.length === 0 ? (
<AccountEmptyState <AccountEmptyState
+1 -22
View File
@@ -22,9 +22,8 @@ import {
export const metadata: Metadata = { title: "Mein Konto" }; export const metadata: Metadata = { title: "Mein Konto" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function KontoPage(props: PageProps<"/konto">) { export default async function KontoPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error, message } = await props.searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) { if (!customer) {
@@ -32,16 +31,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
<div className="py-20"> <div className="py-20">
<Container className="max-w-3xl"> <Container className="max-w-3xl">
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} /> <SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{error}
</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{message}
</p>
) : null}
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0"> <div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
<form action={loginAction} className="space-y-4 md:pr-12"> <form action={loginAction} className="space-y-4 md:pr-12">
<h2 className="text-lg text-forest">{t.account.login}</h2> <h2 className="text-lg text-forest">{t.account.login}</h2>
@@ -134,16 +123,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
<Container className="max-w-2xl"> <Container className="max-w-2xl">
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} /> <SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{error}
</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{message}
</p>
) : null}
{!customer.emailVerifiedAt ? ( {!customer.emailVerifiedAt ? (
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3"> <div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3">
+1 -4
View File
@@ -10,7 +10,7 @@ export const metadata: Metadata = { title: "Passwort vergessen" };
export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) { export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error, sent } = await props.searchParams; const { sent } = await props.searchParams;
return ( return (
<div className="py-20"> <div className="py-20">
@@ -18,9 +18,6 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} /> <BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} /> <SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{sent ? ( {sent ? (
<p className="mt-6 text-sm text-ink/70 leading-relaxed"> <p className="mt-6 text-sm text-ink/70 leading-relaxed">
@@ -13,7 +13,7 @@ export default async function PasswortZuruecksetzenPage(
props: PageProps<"/konto/passwort-zuruecksetzen"> props: PageProps<"/konto/passwort-zuruecksetzen">
) { ) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { token, error } = await props.searchParams; const { token } = await props.searchParams;
return ( return (
<div className="py-20"> <div className="py-20">
@@ -21,9 +21,6 @@ export default async function PasswortZuruecksetzenPage(
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} /> <BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} /> <SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{!token ? ( {!token ? (
<p className="mt-6 text-sm text-ink/70"> <p className="mt-6 text-sm text-ink/70">
+1 -5
View File
@@ -11,9 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Anmeldung bestätigen" }; export const metadata: Metadata = { title: "Anmeldung bestätigen" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-faktor-code">) { export default async function ZweiFaktorCodePage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const customerId = await getTwoFactorChallengeCustomerId(); const customerId = await getTwoFactorChallengeCustomerId();
if (!customerId) redirect("/konto"); if (!customerId) redirect("/konto");
@@ -28,9 +27,6 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f
subtitle={t.twoFactorCodePage.subtitle} subtitle={t.twoFactorCodePage.subtitle}
/> />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={verifyTwoFactorAction} className="mt-6 space-y-4"> <form action={verifyTwoFactorAction} className="mt-6 space-y-4">
<label className="block text-sm"> <label className="block text-sm">
@@ -11,11 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung einrichten" }; export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung einrichten" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ZweiFaktorEinrichtenPage( export default async function ZweiFaktorEinrichtenPage() {
props: PageProps<"/konto/zwei-faktor/einrichten">
) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect("/konto"); if (!customer) redirect("/konto");
@@ -35,9 +32,6 @@ export default async function ZweiFaktorEinrichtenPage(
subtitle={t.twoFactorSetupPage.subtitle} subtitle={t.twoFactorSetupPage.subtitle}
/> />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="mt-8 border border-ink/10 p-6 flex flex-col items-center"> <div className="mt-8 border border-ink/10 p-6 flex flex-col items-center">
{/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */} {/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */}
+1 -8
View File
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" }; export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-faktor">) { export default async function ZweiFaktorPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error, message } = await props.searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect("/konto"); if (!customer) redirect("/konto");
@@ -34,12 +33,6 @@ export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-fakto
subtitle={t.twoFactorPage.subtitle} subtitle={t.twoFactorPage.subtitle}
/> />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{customer.twoFactorEnabled ? ( {customer.twoFactorEnabled ? (
<div className="mt-8 space-y-6"> <div className="mt-8 space-y-6">
+6 -1
View File
@@ -4,6 +4,9 @@ import "./globals.css";
import { Header, CartDrawer } from "@/components/site-chrome"; import { Header, CartDrawer } from "@/components/site-chrome";
import { Footer } from "@/components/site-footer"; import { Footer } from "@/components/site-footer";
import { CookieConsentBanner } from "@/components/cookie-consent"; import { CookieConsentBanner } from "@/components/cookie-consent";
import { FlashToast } from "@/components/flash-toast";
import { SubmitFeedback } from "@/components/submit-feedback";
import { readFlash } from "@/lib/flash";
import { getSiteUrl } from "@/lib/url"; import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getSocialLinks } from "@/lib/social-links"; import { getSocialLinks } from "@/lib/social-links";
@@ -43,7 +46,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const locale = await getLocale(); const locale = await getLocale();
const dict = getDictionary(locale); const dict = getDictionary(locale);
const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]); const [company, socialLinks, flash] = await Promise.all([getCompanyInfo(), getSocialLinks(), readFlash()]);
const base = getSiteUrl(); const base = getSiteUrl();
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt // LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
@@ -133,6 +136,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
<Footer companyInfo={company} socialLinks={socialLinks} /> <Footer companyInfo={company} socialLinks={socialLinks} />
<CartDrawer /> <CartDrawer />
<CookieConsentBanner /> <CookieConsentBanner />
<FlashToast flash={flash} />
<SubmitFeedback flashId={flash?.id ?? null} />
</I18nProvider> </I18nProvider>
</body> </body>
</html> </html>
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noticeUrl } from "@/lib/notice";
import { noIndexPrivate } from "@/lib/seo"; import { noIndexPrivate } from "@/lib/seo";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { Container } from "@/components/ui"; import { Container } from "@/components/ui";
@@ -20,7 +21,7 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { invoiceNumber } = await props.params; const { invoiceNumber } = await props.params;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect(`/konto?error=${encodeURIComponent(t.invoicePage.loginRequiredError)}`); if (!customer) redirect(noticeUrl("invoice-login"));
const invoice = await getInvoiceForCustomer(invoiceNumber, customer.id); const invoice = await getInvoiceForCustomer(invoiceNumber, customer.id);
if (!invoice) notFound(); if (!invoice) notFound();
+6 -1
View File
@@ -23,7 +23,12 @@ export function ConfirmForm({
action={action} action={action}
className={className} className={className}
onSubmit={(e) => { onSubmit={(e) => {
if (!confirm(confirmMessage)) e.preventDefault(); if (!confirm(confirmMessage)) {
e.preventDefault();
// Fuer SubmitFeedback: dieser Klick loest keine Aktion aus, der
// Knopf darf also nicht als "laeuft" markiert werden.
e.currentTarget.dataset.submitCancelled = "true";
}
}} }}
> >
{children} {children}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useI18n } from "@/i18n/i18n-provider";
import { FLASH_COOKIE, type Flash } from "@/lib/flash-core";
// Fehler bleiben laenger stehen: sie verlangen meist eine Reaktion, und wer
// gerade woanders hinschaut, soll sie nicht verpassen.
const AUTO_HIDE_MS: Record<Flash["kind"], number> = { success: 6000, info: 8000, error: 12000 };
const STYLES: Record<Flash["kind"], string> = {
success: "bg-forest text-cream border-forest",
info: "bg-background text-ink border-ink/20",
error: "bg-red-50 text-red-800 border-red-300",
};
/**
* Zeigt die Rueckmeldung der letzten Aktion (lib/flash.ts) als Toast.
* Das Root-Layout reicht die Meldung aus dem Cookie herein; hier wird sie
* angezeigt und das Cookie sofort geloescht, damit sie beim naechsten
* Seitenaufruf nicht erneut erscheint. Jede Meldung traegt eine eigene id —
* dieselbe Aktion zweimal ausgeloest zeigt die Meldung also zweimal.
*/
export function FlashToast({ flash }: { flash: Flash | null }) {
const { t } = useI18n();
const reduceMotion = useReducedMotion();
// Gemerkt wird nur, welche Meldung geschlossen wurde — die sichtbare
// Meldung selbst leitet sich daraus ab, statt im Effect gesetzt zu werden.
const [dismissedId, setDismissedId] = useState<string | null>(null);
const visible = flash && flash.id !== dismissedId ? flash : null;
useEffect(() => {
if (!flash) return;
document.cookie = `${FLASH_COOKIE}=; Max-Age=0; path=/; SameSite=Lax`;
const timer = window.setTimeout(() => setDismissedId(flash.id), AUTO_HIDE_MS[flash.kind]);
return () => window.clearTimeout(timer);
// flash.id statt flash: nach jedem Server-Render ist das Objekt neu, die Meldung aber dieselbe.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flash?.id]);
return (
// Die Live-Region bleibt dauerhaft im DOM, damit Screenreader den
// hinzukommenden Text zuverlaessig ansagen.
<div
className="fixed inset-x-4 top-20 z-[60] flex justify-center pointer-events-none sm:inset-x-auto sm:right-6 sm:top-24"
aria-live={visible?.kind === "error" ? "assertive" : "polite"}
role={visible?.kind === "error" ? "alert" : "status"}
>
<AnimatePresence>
{visible ? (
<motion.div
key={visible.id}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className={`pointer-events-auto flex max-w-md items-start gap-3 border px-4 py-3 text-sm shadow-lg ${STYLES[visible.kind]}`}
>
<p className="flex-1 leading-relaxed">
<span className="sr-only">{t.flash[visible.kind]}: </span>
{visible.message}
</p>
<button
type="button"
onClick={() => setDismissedId(visible.id)}
className="-mr-1 -mt-0.5 px-1 text-lg leading-none opacity-70 hover:opacity-100"
aria-label={t.flash.close}
>
×
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
/** Obergrenze, falls eine Aktion weder weiterleitet noch eine Meldung setzt. */
const MAX_PENDING_MS = 20_000;
function isServerActionForm(form: HTMLFormElement): boolean {
// React rendert fuer Formulare mit Server Action versteckte $ACTION_-Felder
// (progressive enhancement). Formulare mit eigener Client-Logik
// (onSubmit + fetch) haben sie nicht und verwalten ihren Zustand selbst.
return form.querySelector('input[name^="$ACTION_"]') !== null;
}
/**
* Sofortige Rueckmeldung beim Absenden: der geklickte Knopf eines
* Server-Action-Formulars wird als "laeuft" markiert (Spinner, siehe
* globals.css) und bis zur Antwort gesperrt — ohne das sah ein Klick bis zur
* Serverantwort wie "nichts passiert" aus und wurde oft wiederholt
* (doppelte Aktion, doppelte E-Mail).
*
* Zentral statt in jedem der vielen Formulare: freigegeben wird, sobald eine
* neue Flash-Meldung (flashId) oder eine neue Seite kommt, spaetestens nach
* MAX_PENDING_MS.
*/
export function SubmitFeedback({ flashId }: { flashId: string | null }) {
const pathname = usePathname();
useEffect(() => {
const onSubmit = (event: SubmitEvent) => {
const form = event.target;
const button = event.submitter;
if (!(form instanceof HTMLFormElement) || !(button instanceof HTMLButtonElement)) return;
if (!isServerActionForm(form)) return;
// Erst nach den React-Handlern pruefen: ConfirmForm markiert dort einen
// im Dialog abgebrochenen Klick. Das Sperren nach dem Absenden aendert
// die bereits erfassten Formulardaten nicht mehr.
window.setTimeout(() => {
if (form.dataset.submitCancelled === "true") {
delete form.dataset.submitCancelled;
return;
}
button.dataset.pending = "true";
button.setAttribute("aria-busy", "true");
button.disabled = true;
window.setTimeout(() => release(button), MAX_PENDING_MS);
}, 0);
};
document.addEventListener("submit", onSubmit, true);
return () => document.removeEventListener("submit", onSubmit, true);
}, []);
useEffect(() => {
document.querySelectorAll<HTMLButtonElement>('button[data-pending="true"]').forEach(release);
}, [flashId, pathname]);
return null;
}
function release(button: HTMLButtonElement): void {
if (button.dataset.pending !== "true") return;
delete button.dataset.pending;
button.removeAttribute("aria-busy");
button.disabled = false;
}
+6
View File
@@ -927,6 +927,12 @@ export const de: Dictionary = {
unknown: "Status unbekannt", unknown: "Status unbekannt",
}, },
}, },
flash: {
close: "Meldung schließen",
success: "Erledigt",
error: "Fehler",
info: "Hinweis",
},
adminArchive: { adminArchive: {
tabsAriaLabel: "Ansicht", tabsAriaLabel: "Ansicht",
tabActive: "Aktiv", tabActive: "Aktiv",
+6
View File
@@ -915,6 +915,12 @@ export const en: Dictionary = {
unknown: "Status unknown", unknown: "Status unknown",
}, },
}, },
flash: {
close: "Close message",
success: "Done",
error: "Error",
info: "Notice",
},
adminArchive: { adminArchive: {
tabsAriaLabel: "View", tabsAriaLabel: "View",
tabActive: "Active", tabActive: "Active",
+6
View File
@@ -603,6 +603,12 @@ export interface Dictionary {
unknown: string; unknown: string;
}; };
}; };
flash: {
close: string;
success: string;
error: string;
info: string;
};
adminArchive: { adminArchive: {
tabsAriaLabel: string; tabsAriaLabel: string;
tabActive: string; tabActive: string;
+2 -2
View File
@@ -16,7 +16,7 @@ export function hashVerificationToken(token: string): string {
* Link. Wird nach der Selbst-Registrierung aufgerufen sowie ueber den * Link. Wird nach der Selbst-Registrierung aufgerufen sowie ueber den
* "Link erneut senden"-Button auf /konto, solange das Konto unbestaetigt ist. * "Link erneut senden"-Button auf /konto, solange das Konto unbestaetigt ist.
*/ */
export async function createAndSendVerificationEmail(customer: { id: string; email: string }): Promise<void> { export async function createAndSendVerificationEmail(customer: { id: string; email: string }): Promise<boolean> {
const rawToken = randomBytes(32).toString("hex"); const rawToken = randomBytes(32).toString("hex");
await prisma.emailVerificationToken.deleteMany({ where: { customerId: customer.id, usedAt: null } }); await prisma.emailVerificationToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
await prisma.emailVerificationToken.create({ await prisma.emailVerificationToken.create({
@@ -29,5 +29,5 @@ export async function createAndSendVerificationEmail(customer: { id: string; ema
const verifyUrl = `${getBaseUrl()}/api/konto/email-bestaetigen?token=${rawToken}`; const verifyUrl = `${getBaseUrl()}/api/konto/email-bestaetigen?token=${rawToken}`;
const { subject, html, text } = await emailVerificationEmail(await getLocale(), verifyUrl); const { subject, html, text } = await emailVerificationEmail(await getLocale(), verifyUrl);
await sendMail({ to: customer.email, subject, html, text }); return sendMail({ to: customer.email, subject, html, text });
} }
+61
View File
@@ -0,0 +1,61 @@
/**
* Reine Hilfsfunktionen der Flash-Meldungen (siehe lib/flash.ts) ohne
* next/headers — damit auch der Proxy (src/proxy.ts) und Route Handler sie
* nutzen koennen.
*/
export const FLASH_COOKIE = "wh_flash";
export const MAX_FLASH_MESSAGE_LENGTH = 300;
/** Ohne JavaScript loescht niemand das Cookie — dann verfaellt es von selbst. */
const FLASH_MAX_AGE_SECONDS = 60;
const FLASH_KINDS = ["success", "error", "info"] as const;
export type FlashKind = (typeof FLASH_KINDS)[number];
export interface Flash {
id: string;
kind: FlashKind;
message: string;
}
export function serializeFlash(flash: Flash): string {
return encodeURIComponent(JSON.stringify({ ...flash, message: flash.message.slice(0, MAX_FLASH_MESSAGE_LENGTH) }));
}
export function parseFlash(raw: string | undefined): Flash | null {
if (!raw) return null;
let value: unknown;
try {
value = JSON.parse(decodeURIComponent(raw));
} catch {
return null;
}
if (typeof value !== "object" || value === null) return null;
const { id, kind, message } = value as Record<string, unknown>;
if (typeof id !== "string" || typeof message !== "string" || message.trim() === "") return null;
if (!(FLASH_KINDS as readonly unknown[]).includes(kind)) return null;
return { id: id.slice(0, 64), kind: kind as FlashKind, message: message.slice(0, MAX_FLASH_MESSAGE_LENGTH) };
}
export function flashCookieOptions() {
return {
path: "/",
maxAge: FLASH_MAX_AGE_SECONDS,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
httpOnly: false,
};
}
/**
* Fuer Route Handler, die selbst eine NextResponse zurueckgeben (z.B. der
* Link aus der Bestaetigungs-E-Mail): haengt die Meldung direkt an die
* Weiterleitung.
*/
export function withFlash<T extends { cookies: { set: (name: string, value: string, options: object) => unknown } }>(
response: T,
kind: FlashKind,
message: string
): T {
response.cookies.set(FLASH_COOKIE, serializeFlash({ id: crypto.randomUUID(), kind, message }), flashCookieOptions());
return response;
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { parseFlash, serializeFlash, MAX_FLASH_MESSAGE_LENGTH } from "./flash";
describe("serializeFlash / parseFlash", () => {
it("uebertraegt Art und Text unveraendert", () => {
const raw = serializeFlash({ id: "abc", kind: "success", message: "Retoure angenommen." });
expect(parseFlash(raw)).toEqual({ id: "abc", kind: "success", message: "Retoure angenommen." });
});
it("kuerzt ueberlange Texte, damit das Cookie klein bleibt", () => {
const raw = serializeFlash({ id: "abc", kind: "error", message: "x".repeat(5000) });
expect(parseFlash(raw)?.message.length).toBe(MAX_FLASH_MESSAGE_LENGTH);
});
it.each([
["fehlendes Cookie", undefined],
["kein JSON", "%%%"],
["unbekannte Art", encodeURIComponent(JSON.stringify({ id: "a", kind: "html", message: "x" }))],
["leerer Text", encodeURIComponent(JSON.stringify({ id: "a", kind: "info", message: "" }))],
])("verwirft %s", (_label, raw) => {
expect(parseFlash(raw)).toBeNull();
});
});
/**
* fail()/succeed()/setFlash() sind asynchron (Cookies lassen sich in
* Next.js 16 nur asynchron setzen). Ohne "await" liefe eine Server Action
* nach einem fail() — etwa nach einer gescheiterten Berechtigungs- oder
* Eingabepruefung — einfach weiter. ESLint hat dafuer keine Regel aktiv,
* deshalb diese Pruefung ueber den Quelltext.
*/
describe("Aufrufe der Rueckmelde-Helfer", () => {
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === "generated" ? [] : sourceFiles(full);
return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [full] : [];
});
}
it("werden ausnahmslos mit await (oder return) aufgerufen", () => {
const srcDir = path.join(__dirname, "..");
const offenders = sourceFiles(srcDir).flatMap((file) =>
readFileSync(file, "utf8")
.split("\n")
.map((line, i) => ({ line: line.trim(), where: `${path.relative(srcDir, file)}:${i + 1}` }))
// "[^)]": nur echte Aufrufe mit Argument, keine Erwaehnung wie "fail()" in Kommentaren
.filter(({ line }) => /\b(fail|setFlash|succeed)\([^)]/.test(line))
.filter(({ line }) => !/^(export )?(async )?function /.test(line))
.filter(({ line }) => !/^(\/\/|\*|\/\*)/.test(line))
.filter(({ line }) => !/\b(await|return) (fail|setFlash|succeed)\(/.test(line))
.map(({ where, line }) => `${where}: ${line}`)
);
expect(offenders).toEqual([]);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Rueckmeldungen ("Flash-Meldungen") nach einer Aktion — z.B. "Retoure
* angenommen" oder "Code ungueltig" — als kurzlebiges Cookie statt als
* ?message=/?error= in der URL.
*
* Warum kein URL-Parameter mehr: jeder konnte einen Link wie
* /konto?error=Bitte%20ruf%200900...%20an bauen, und der Text erschien im
* Wabenhain-Layout wie eine echte Systemmeldung (Phishing-Hebel). Ein Cookie
* fuer diese Domain kann eine fremde Seite nicht setzen. Nebenbei erscheint
* dieselbe Meldung beim zweiten Klick erneut (neue id), statt dass sich
* die URL nicht aendert und scheinbar nichts passiert.
*
* Ablauf: Server Action ruft setFlash() -> Next.js rendert Seite und Layouts
* neu, weil ein Cookie gesetzt wurde -> das Root-Layout liest die Meldung
* (readFlash) und reicht sie an <FlashToast> weiter -> der Client zeigt sie
* an und loescht das Cookie. Deshalb ist das Cookie NICHT httpOnly; es
* enthaelt nichts Geheimes und wird ausschliesslich als React-Text
* gerendert, nie als HTML.
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {
FLASH_COOKIE,
flashCookieOptions,
parseFlash,
serializeFlash,
type Flash,
type FlashKind,
} from "@/lib/flash-core";
export {
FLASH_COOKIE,
MAX_FLASH_MESSAGE_LENGTH,
parseFlash,
serializeFlash,
withFlash,
type Flash,
type FlashKind,
} from "@/lib/flash-core";
/** Nur in Server Actions und Route Handlern aufrufbar (dort sind Cookies schreibbar). */
export async function setFlash(kind: FlashKind, message: string): Promise<void> {
const store = await cookies();
store.set(FLASH_COOKIE, serializeFlash({ id: crypto.randomUUID(), kind, message }), flashCookieOptions());
}
export async function readFlash(): Promise<Flash | null> {
const store = await cookies();
return parseFlash(store.get(FLASH_COOKIE)?.value);
}
/**
* Meldet einen Fehler als Flash-Meldung und leitet auf path weiter. Immer
* mit await aufrufen — sonst laeuft die Action nach dem Fehler weiter
* (abgesichert durch flash.test.ts).
*/
export async function fail(path: string, message: string): Promise<never> {
await setFlash("error", message);
redirect(path);
}
/** Erfolgsmeldung setzen und weiterleiten. */
export async function succeed(path: string, message: string): Promise<never> {
await setFlash("success", message);
redirect(path);
}
/** Einheitlicher Zusatz, wenn eine Aktion geklappt hat, die zugehoerige E-Mail aber nicht rausging. */
export const MAIL_FAILED_HINT = "Die E-Mail konnte nicht versendet werden — bitte Mailserver (SMTP) prüfen.";
+10 -2
View File
@@ -66,8 +66,14 @@ function getTransport() {
* damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen. * damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen.
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung * Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung
* oder Registrierung nicht verhindern. * oder Registrierung nicht verhindern.
*
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
* sichtbarer Rueckmeldung ("Link erneut gesendet", "Kundschaft informiert")
* duerfen Erfolg nur melden, wenn hier true zurueckkam — sonst wartet die
* Kundschaft auf eine Mail, die nie kommt.
*/ */
export async function sendMail(input: SendMailInput): Promise<void> { export async function sendMail(input: SendMailInput): Promise<boolean> {
const transport = getTransport(); const transport = getTransport();
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>"; const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
@@ -75,7 +81,7 @@ export async function sendMail(input: SendMailInput): Promise<void> {
console.warn( console.warn(
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.` `[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
); );
return; return false;
} }
try { try {
@@ -87,8 +93,10 @@ export async function sendMail(input: SendMailInput): Promise<void> {
text: input.text, text: input.text,
...(input.attachments?.length ? { attachments: input.attachments } : {}), ...(input.attachments?.length ? { attachments: input.attachments } : {}),
}); });
return true;
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`); console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
return false;
} }
} }
+33
View File
@@ -0,0 +1,33 @@
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
/**
* Feste Hinweise fuer Weiterleitungen aus Server Components, die selbst
* keine Cookies setzen duerfen (siehe app/api/hinweis/route.ts).
*
* Bewusst nur feste Codes mit festem Ziel — weder Text noch Ziel kommen aus
* der URL. Sonst liesse sich ueber den Endpunkt beliebiger Text im
* Wabenhain-Layout anzeigen (Phishing) oder auf fremde Seiten weiterleiten
* (Open Redirect).
*/
export const NOTICES = {
"admin-login": { to: "/konto", message: async () => "Bitte melde dich mit einem Admin-Konto an." },
"admin-2fa": {
to: "/konto/zwei-faktor",
message: async () => "Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.",
},
"invoice-login": {
to: "/konto",
message: async () => getDictionary(await getLocale()).invoicePage.loginRequiredError,
},
} as const satisfies Record<string, { to: string; message: () => Promise<string> }>;
export type NoticeCode = keyof typeof NOTICES;
export function isNoticeCode(value: string | null): value is NoticeCode {
return value !== null && Object.hasOwn(NOTICES, value);
}
export function noticeUrl(code: NoticeCode): string {
return `/api/hinweis?code=${code}`;
}
+2 -2
View File
@@ -17,7 +17,7 @@ export function hashResetToken(token: string): string {
* und der Admin-Benutzerverwaltung (statt dass ein Admin dort direkt ein * und der Admin-Benutzerverwaltung (statt dass ein Admin dort direkt ein
* neues Passwort setzen kann, bekommt der Kunde immer nur diesen Link). * neues Passwort setzen kann, bekommt der Kunde immer nur diesen Link).
*/ */
export async function createAndSendPasswordResetEmail(customer: { id: string; email: string }): Promise<void> { export async function createAndSendPasswordResetEmail(customer: { id: string; email: string }): Promise<boolean> {
const rawToken = randomBytes(32).toString("hex"); const rawToken = randomBytes(32).toString("hex");
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id, usedAt: null } }); await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
await prisma.passwordResetToken.create({ await prisma.passwordResetToken.create({
@@ -30,5 +30,5 @@ export async function createAndSendPasswordResetEmail(customer: { id: string; em
const resetUrl = `${getBaseUrl()}/konto/passwort-zuruecksetzen?token=${rawToken}`; const resetUrl = `${getBaseUrl()}/konto/passwort-zuruecksetzen?token=${rawToken}`;
const { subject, html, text } = await passwordResetEmail(await getLocale(), resetUrl); const { subject, html, text } = await passwordResetEmail(await getLocale(), resetUrl);
await sendMail({ to: customer.email, subject, html, text }); return sendMail({ to: customer.email, subject, html, text });
} }
+5 -3
View File
@@ -1,3 +1,4 @@
import { withFlash } from "@/lib/flash-core";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { SESSION_COOKIE, verifySessionToken } from "@/lib/session"; import { SESSION_COOKIE, verifySessionToken } from "@/lib/session";
@@ -65,14 +66,15 @@ export async function proxy(request: NextRequest) {
return withCsp(new NextResponse("Zugriff verweigert.", { status: 403 })); return withCsp(new NextResponse("Zugriff verweigert.", { status: 403 }));
} }
const loginUrl = new URL("/konto", request.url); return withCsp(
loginUrl.searchParams.set( withFlash(
NextResponse.redirect(new URL("/konto", request.url)),
"error", "error",
session session
? "Dein Konto hat keinen Zugriff auf den Admin-Bereich." ? "Dein Konto hat keinen Zugriff auf den Admin-Bereich."
: "Bitte melde dich an, um den Admin-Bereich zu nutzen." : "Bitte melde dich an, um den Admin-Bereich zu nutzen."
)
); );
return withCsp(NextResponse.redirect(loginUrl));
} }
} }