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:
1 parent
fb9c2353c6
commit
839bbff595
71 files changed
+757
-411
No files matched your search
@@ -1,4 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import type { CustomerRole } from "@/generated/prisma/client";
|
||||
@@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role:
|
||||
return admin;
|
||||
}
|
||||
|
||||
export function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
export { fail, succeed } from "@/lib/flash";
|
||||
|
||||
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
||||
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import type { ArchiveKind } from "@/lib/archive";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
import { requireAdmin, str, fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
const LIST_PATH: Record<ArchiveKind, string> = {
|
||||
orders: "/admin/bestellungen",
|
||||
@@ -35,7 +36,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
||||
const kind = str(formData, "kind");
|
||||
const id = str(formData, "id");
|
||||
const archive = str(formData, "mode") === "archive";
|
||||
if (!isArchiveKind(kind) || !id) return;
|
||||
if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage.");
|
||||
|
||||
const data = archive
|
||||
? { archivedAt: new Date(), archivedManually: true }
|
||||
@@ -51,7 +52,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
||||
: kind === "returns"
|
||||
? await prisma.returnRequest.updateMany({ where, data })
|
||||
: await prisma.product.updateMany({ where, data });
|
||||
if (result.count === 0) return;
|
||||
if (result.count === 0) return fail(LIST_PATH[kind], "Eintrag nicht gefunden.");
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: archive ? "archive.manual_archive" : "archive.manual_restore",
|
||||
@@ -60,4 +61,5 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath(LIST_PATH[kind]);
|
||||
await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt.");
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
/**
|
||||
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
||||
@@ -70,5 +70,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
});
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/admin/inhalte?saved=1");
|
||||
return succeed("/admin/inhalte", "Inhalte gespeichert.");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -10,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
|
||||
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -21,12 +21,12 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
role: str(formData, "role") || "KUNDE",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (existing) {
|
||||
fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
||||
return fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
||||
}
|
||||
|
||||
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
||||
@@ -43,7 +43,7 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
await createAndSendPasswordResetEmail(customer);
|
||||
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.create",
|
||||
entityType: "Customer",
|
||||
@@ -52,7 +52,13 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
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> {
|
||||
@@ -67,23 +73,23 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||
role: str(formData, "role") || "KUNDE",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (id === admin.id && parsed.data.role !== admin.role) {
|
||||
fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
||||
return fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
||||
}
|
||||
|
||||
const target = await prisma.customer.findUnique({ where: { id } });
|
||||
if (!target) fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
if (!target) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
|
||||
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
|
||||
fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
||||
return fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
||||
}
|
||||
|
||||
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (emailOwner && emailOwner.id !== id) {
|
||||
fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
||||
return fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
||||
}
|
||||
|
||||
const emailChanged = parsed.data.email !== target.email;
|
||||
@@ -116,7 +122,7 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
return succeed("/admin/benutzer", "Änderungen gespeichert.");
|
||||
}
|
||||
|
||||
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");
|
||||
if (id === admin.id) {
|
||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
||||
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
||||
}
|
||||
if (await isLastAdmin(id)) {
|
||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
||||
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
||||
}
|
||||
|
||||
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
||||
@@ -139,6 +145,7 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
||||
revalidatePath("/admin/benutzer");
|
||||
await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`);
|
||||
}
|
||||
|
||||
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||
@@ -156,6 +163,7 @@ export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||
detail: unblocked.email,
|
||||
});
|
||||
revalidatePath("/admin/benutzer");
|
||||
await setFlash("success", `${unblocked.email} wurde entsperrt.`);
|
||||
}
|
||||
|
||||
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");
|
||||
if (id === admin.id) {
|
||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
||||
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
||||
}
|
||||
if (await isLastAdmin(id)) {
|
||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
const deleted = await prisma.customer.delete({ where: { id } });
|
||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
||||
}
|
||||
|
||||
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
||||
@@ -181,14 +189,15 @@ export async function sendCustomerPasswordResetAction(formData: FormData): Promi
|
||||
const id = str(formData, "id");
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { id } });
|
||||
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
if (!customer) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
|
||||
await createAndSendPasswordResetEmail(customer);
|
||||
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.password_reset_sent",
|
||||
entityType: "Customer",
|
||||
entityId: id,
|
||||
detail: customer.email,
|
||||
});
|
||||
redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt."));
|
||||
if (!mailSent) return fail(`/admin/benutzer/${id}`, MAIL_FAILED_HINT);
|
||||
return succeed(`/admin/benutzer/${id}`, "Passwort-Reset-E-Mail wurde verschickt.");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
|
||||
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
|
||||
@@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
},
|
||||
});
|
||||
if (!order) {
|
||||
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||
return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||
}
|
||||
|
||||
if (order.fulfillmentMethod !== "VERSAND") {
|
||||
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||
return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||
}
|
||||
if (order.payment?.status !== "SUCCEEDED") {
|
||||
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||
return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||
}
|
||||
if (order.shipment?.trackingNumber) {
|
||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||
}
|
||||
|
||||
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
|
||||
@@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
claimed = false;
|
||||
}
|
||||
if (!claimed) {
|
||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
||||
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
||||
}
|
||||
|
||||
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
|
||||
@@ -126,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
.catch(() => {});
|
||||
|
||||
if (error instanceof DhlNotConfiguredError) {
|
||||
fail(detailPath, error.message);
|
||||
return fail(detailPath, error.message);
|
||||
}
|
||||
if (error instanceof DhlApiError) {
|
||||
fail(detailPath, error.message);
|
||||
return fail(detailPath, error.message);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||
return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -153,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
|
||||
error
|
||||
);
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
|
||||
);
|
||||
@@ -168,4 +169,5 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
revalidatePath(detailPath);
|
||||
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { discountCodeSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
function parseDiscountCodeForm(formData: FormData) {
|
||||
const rawMax = str(formData, "maxUses");
|
||||
@@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseDiscountCodeForm(formData);
|
||||
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 } });
|
||||
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 });
|
||||
@@ -48,7 +48,7 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
return succeed("/admin/gutscheine", "Gutschein angelegt.");
|
||||
}
|
||||
|
||||
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
||||
@@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseDiscountCodeForm(formData);
|
||||
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 } });
|
||||
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 });
|
||||
@@ -75,7 +75,7 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
return succeed("/admin/gutscheine", "Gutschein gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
||||
@@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
||||
try {
|
||||
deleted = await prisma.discountCode.delete({ where: { id } });
|
||||
} 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, {
|
||||
@@ -100,5 +100,5 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
return succeed("/admin/gutscheine", "Gutschein gelöscht.");
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
/**
|
||||
* 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("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Ernte angelegt.");
|
||||
}
|
||||
|
||||
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
@@ -80,7 +80,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Ernte gespeichert.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,10 +99,10 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
||||
where: { id },
|
||||
include: { _count: { select: { batches: true, products: true } } },
|
||||
});
|
||||
if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden.");
|
||||
if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden.");
|
||||
|
||||
if (harvest._count.batches > 0 || harvest._count.products > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/ernten",
|
||||
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
|
||||
);
|
||||
@@ -119,7 +119,7 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Ernte gelöscht.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +148,7 @@ export async function toggleHarvestPublishedAction(formData: FormData): Promise<
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
|
||||
}
|
||||
|
||||
export async function createBatchAction(formData: FormData): Promise<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 });
|
||||
|
||||
revalidatePath("/admin/chargen");
|
||||
redirect("/admin/chargen");
|
||||
return succeed("/admin/chargen", "Charge angelegt.");
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
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 },
|
||||
include: { _count: { select: { products: true, orderItems: true } } },
|
||||
});
|
||||
if (!batch) fail("/admin/chargen", "Charge nicht gefunden.");
|
||||
if (!batch) return fail("/admin/chargen", "Charge nicht gefunden.");
|
||||
|
||||
if (batch._count.orderItems > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/chargen",
|
||||
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
|
||||
);
|
||||
}
|
||||
if (batch._count.products > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/chargen",
|
||||
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
|
||||
);
|
||||
@@ -230,5 +230,5 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
|
||||
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
|
||||
|
||||
revalidatePath("/admin/chargen");
|
||||
redirect("/admin/chargen");
|
||||
return succeed("/admin/chargen", "Charge gelöscht.");
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
import { requireAdmin, str, fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const status = str(formData, "status");
|
||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return;
|
||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status.");
|
||||
|
||||
await prisma.businessInquiry.update({
|
||||
where: { id },
|
||||
@@ -22,6 +23,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro
|
||||
entityId: id,
|
||||
detail: status,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/anfragen");
|
||||
await setFlash("success", "Status der Anfrage gespeichert.");
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
@@ -13,6 +12,7 @@ import { getStripeClient } from "@/lib/stripe";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
|
||||
const ORDER_STATUSES = [
|
||||
"NEU",
|
||||
@@ -124,7 +124,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||
if (!ORDER_STATUSES.includes(status)) {
|
||||
fail(detailPath, "Ungültiger Status.");
|
||||
return fail(detailPath, "Ungültiger Status.");
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
@@ -198,9 +198,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||
}
|
||||
|
||||
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||
let mailSent: boolean | undefined;
|
||||
|
||||
if (result) {
|
||||
await logAdminAction(admin, {
|
||||
action: "order.status_change",
|
||||
@@ -238,7 +241,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
|
||||
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
|
||||
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
|
||||
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
||||
return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +260,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
trackingNumber,
|
||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
||||
const { subject, html, text } = await pickupReadyEmail({
|
||||
@@ -265,11 +268,14 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
orderNumber: result.orderNumber,
|
||||
firstName: result.firstName,
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
const done = `Status von ${result.orderNumber} gespeichert.`;
|
||||
if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||
await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,7 +330,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
},
|
||||
});
|
||||
if (!order) {
|
||||
fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
|
||||
return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
|
||||
}
|
||||
|
||||
const verdict = checkOrderDeletable({
|
||||
@@ -333,7 +339,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
hasInvoice: order.invoice !== null,
|
||||
});
|
||||
if (!verdict.deletable) {
|
||||
fail(detailPath, {
|
||||
return fail(detailPath, {
|
||||
invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`,
|
||||
payment:
|
||||
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.",
|
||||
@@ -351,7 +357,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
} else if (session.status === "complete") {
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren."
|
||||
);
|
||||
@@ -365,7 +371,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
|
||||
error
|
||||
);
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen."
|
||||
);
|
||||
@@ -394,7 +400,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden."
|
||||
);
|
||||
@@ -410,9 +416,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
redirect(
|
||||
`/admin/bestellungen?message=${encodeURIComponent(`Bestellung ${order.orderNumber} wurde gelöscht.`)}`
|
||||
);
|
||||
return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,7 +437,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
||||
where: { id: orderId },
|
||||
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
||||
});
|
||||
if (!order) return;
|
||||
if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||
|
||||
await prisma.shipment.upsert({
|
||||
where: { orderId },
|
||||
@@ -448,6 +452,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
||||
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
||||
});
|
||||
|
||||
let mailSent: boolean | undefined;
|
||||
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
||||
const { subject, html, text } = await shipmentEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
@@ -457,9 +462,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
||||
trackingNumber,
|
||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
mailSent = await sendMail({ to: order.email, subject, html, text });
|
||||
}
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
revalidatePath(`/admin/bestellungen/${orderId}`);
|
||||
if (mailSent === false) {
|
||||
return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`);
|
||||
}
|
||||
await setFlash(
|
||||
"success",
|
||||
mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert."
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
@@ -8,6 +7,7 @@ import { notifyStockAvailable } from "@/lib/stock-notifications";
|
||||
import { adminProductNumbersSchema } from "@/lib/validation";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
/**
|
||||
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
|
||||
@@ -18,9 +18,9 @@ import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
||||
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
|
||||
* Produkt bestellbar.
|
||||
*/
|
||||
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
|
||||
async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise<void> {
|
||||
if (isActive && priceCents <= 0) {
|
||||
fail(
|
||||
return fail(
|
||||
redirectPath,
|
||||
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
|
||||
);
|
||||
@@ -42,12 +42,12 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
vatRatePercent: str(formData, "vatRatePercent"),
|
||||
});
|
||||
if (!parsedNumbers.success) {
|
||||
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
const numbers = parsedNumbers.data;
|
||||
|
||||
const isActive = formData.get("isActive") === "on";
|
||||
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
||||
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
||||
|
||||
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
||||
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
||||
@@ -64,7 +64,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
// hervorgeht, dass lediglich die Adresse belegt ist.
|
||||
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
||||
if (slugTaken) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/produkte/neu",
|
||||
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
|
||||
);
|
||||
@@ -101,7 +101,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
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 },
|
||||
include: { _count: { select: { orderItems: true } } },
|
||||
});
|
||||
if (!product) fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||
if (!product) return fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||
|
||||
if (product._count.orderItems > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/produkte",
|
||||
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
|
||||
);
|
||||
@@ -150,7 +150,7 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/produkte");
|
||||
redirect("/admin/produkte");
|
||||
return succeed("/admin/produkte", "Produkt gelöscht.");
|
||||
}
|
||||
|
||||
export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
@@ -168,12 +168,12 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
vatRatePercent: str(formData, "vatRatePercent"),
|
||||
});
|
||||
if (!parsedNumbers.success) {
|
||||
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
const numbers = parsedNumbers.data;
|
||||
|
||||
const isActive = formData.get("isActive") === "on";
|
||||
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||
|
||||
const previousInventory = await prisma.inventory.findUnique({
|
||||
where: { productId: id },
|
||||
@@ -216,5 +216,5 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
revalidatePath("/admin/produkte");
|
||||
redirect("/admin/produkte");
|
||||
return succeed("/admin/produkte", "Produkt gespeichert.");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -24,6 +25,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/bewertungen");
|
||||
revalidatePath(`/honig/${review.product.slug}`);
|
||||
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
|
||||
}
|
||||
|
||||
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||
@@ -44,4 +46,5 @@ export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/bewertungen");
|
||||
revalidatePath(`/honig/${review.product.slug}`);
|
||||
await setFlash("success", "Bewertung gelöscht.");
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { shippingTierSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
function parseShippingTierForm(formData: FormData) {
|
||||
const rawMax = str(formData, "maxQuantity");
|
||||
@@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseShippingTierForm(formData);
|
||||
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({
|
||||
@@ -42,7 +42,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
return succeed("/admin/versand", "Versandstufe angelegt.");
|
||||
}
|
||||
|
||||
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
||||
@@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseShippingTierForm(formData);
|
||||
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({
|
||||
@@ -68,7 +68,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
return succeed("/admin/versand", "Versandstufe gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
||||
@@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise<void
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
return succeed("/admin/versand", "Versandstufe gelöscht.");
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error, message } = await props.searchParams;
|
||||
|
||||
const [admin, customer] = await Promise.all([
|
||||
getCurrentAdmin(),
|
||||
@@ -36,12 +35,6 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
|
||||
</h1>
|
||||
<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">
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
|
||||
@@ -3,9 +3,8 @@ import { createCustomerAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) {
|
||||
export default async function AdminBenutzerNewPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const roleLabels: Record<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 [admin, customers] = await Promise.all([
|
||||
@@ -42,12 +42,6 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
||||
</Link>
|
||||
</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">
|
||||
<label className="text-sm">
|
||||
|
||||
@@ -19,7 +19,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { id } = await props.params;
|
||||
const { error, message } = await props.searchParams;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -47,12 +46,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
<div className="max-w-3xl">
|
||||
<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>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive
|
||||
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
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 statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
||||
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
|
||||
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
|
||||
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
|
||||
basePath="/admin/bestellungen"
|
||||
|
||||
@@ -7,9 +7,8 @@ import { createBatchAction, deleteBatchAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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 { error } = await props.searchParams;
|
||||
const [batches, harvests] = await Promise.all([
|
||||
prisma.batch.findMany({
|
||||
orderBy: { fillDate: "desc" },
|
||||
@@ -22,9 +21,6 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
||||
<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">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const searchParams = await props.searchParams;
|
||||
const error = searchParams.error;
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||
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} />
|
||||
<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} />
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
|
||||
@@ -13,7 +13,6 @@ function toDateInputValue(date: Date | null): string {
|
||||
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const code = await prisma.discountCode.findUnique({ where: { id } });
|
||||
if (!code) notFound();
|
||||
|
||||
@@ -22,9 +21,6 @@ export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gu
|
||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
|
||||
<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">
|
||||
<input type="hidden" name="id" value={code.id} />
|
||||
|
||||
@@ -3,18 +3,14 @@ import { createDiscountCodeAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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 { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
|
||||
<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">
|
||||
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
|
||||
|
||||
@@ -7,9 +7,8 @@ import { deleteDiscountCodeAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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 { error } = await props.searchParams;
|
||||
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||
|
||||
return (
|
||||
@@ -22,9 +21,6 @@ export default async function AdminGutscheinePage(props: PageProps<"/admin/gutsc
|
||||
</Link>
|
||||
</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">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -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 t = getDictionary(locale);
|
||||
const { saved } = await props.searchParams;
|
||||
const current = await loadCurrentValues();
|
||||
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { noticeUrl } from "@/lib/notice";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { logoutAction } from "@/app/konto/actions";
|
||||
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).
|
||||
const admin = await getCurrentAdmin();
|
||||
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
|
||||
@@ -38,10 +39,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
|
||||
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
|
||||
if (!admin.twoFactorEnabled) {
|
||||
redirect(
|
||||
"/konto/zwei-faktor?error=" +
|
||||
encodeURIComponent("Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.")
|
||||
);
|
||||
redirect(noticeUrl("admin-2fa"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const rawProduct = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { harvest: true, batch: true, inventory: true },
|
||||
@@ -36,12 +35,6 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
||||
)
|
||||
: null}
|
||||
</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">
|
||||
<input type="hidden" name="id" value={product.id} />
|
||||
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
||||
|
||||
@@ -5,9 +5,8 @@ import { createProductAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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 { error } = await props.searchParams;
|
||||
const [harvests, batches] = await Promise.all([
|
||||
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||
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">
|
||||
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
|
||||
<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">
|
||||
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
|
||||
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { fail, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/app/admin/actions";
|
||||
@@ -15,15 +15,12 @@ function str(formData: FormData, key: string): string {
|
||||
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> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
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({
|
||||
where: { id },
|
||||
@@ -31,7 +28,9 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
|
||||
});
|
||||
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
|
||||
// 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({
|
||||
where: { id },
|
||||
@@ -51,9 +50,12 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
|
||||
status === "ANGENOMMEN"
|
||||
? await returnAcceptedEmail({ 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");
|
||||
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> {
|
||||
@@ -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;
|
||||
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
|
||||
@@ -87,7 +91,7 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
||||
where: { id, status: "ANGENOMMEN" },
|
||||
data: { status: "ERSTATTET" },
|
||||
});
|
||||
if (claimed.count === 0) return;
|
||||
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
|
||||
|
||||
const stripe = getStripeClient();
|
||||
try {
|
||||
@@ -116,7 +120,10 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
||||
orderNumber: returnRequest.order.orderNumber,
|
||||
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");
|
||||
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.`);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const tier = await prisma.shippingTier.findUnique({ where: { id } });
|
||||
if (!tier) notFound();
|
||||
|
||||
@@ -17,9 +16,6 @@ export default async function AdminVersandEditPage(props: PageProps<"/admin/vers
|
||||
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
|
||||
<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">
|
||||
<input type="hidden" name="id" value={tier.id} />
|
||||
|
||||
@@ -3,18 +3,14 @@ import { createShippingTierAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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 { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -7,9 +7,8 @@ import { deleteShippingTierAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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 { error } = await props.searchParams;
|
||||
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||
|
||||
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>
|
||||
|
||||
{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">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withFlash } from "@/lib/flash";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import {
|
||||
@@ -50,9 +51,7 @@ export async function GET(request: Request) {
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
function redirectWithError(message: string): NextResponse {
|
||||
const url = new URL("/konto", baseUrl);
|
||||
url.searchParams.set("error", message);
|
||||
return NextResponse.redirect(url);
|
||||
return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message);
|
||||
}
|
||||
|
||||
if (!isEntraConfigured()) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withFlash } from "@/lib/flash";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { setEntraOAuthCookie } from "@/lib/auth";
|
||||
import { signEntraOAuthState } from "@/lib/session";
|
||||
@@ -12,9 +13,11 @@ import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOA
|
||||
*/
|
||||
export async function GET() {
|
||||
if (!isEntraConfigured()) {
|
||||
const url = new URL("/konto", getBaseUrl());
|
||||
url.searchParams.set("error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an.");
|
||||
return NextResponse.redirect(url);
|
||||
return withFlash(
|
||||
NextResponse.redirect(new URL("/konto", getBaseUrl())),
|
||||
"error",
|
||||
"SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."
|
||||
);
|
||||
}
|
||||
|
||||
const { codeVerifier, codeChallenge } = generatePkcePair();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashVerificationToken } from "@/lib/email-verification";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { withFlash } from "@/lib/flash";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
const fail = (message: string) =>
|
||||
NextResponse.redirect(`${baseUrl}/konto?error=${encodeURIComponent(message)}`);
|
||||
const fail = (message: string) => withFlash(NextResponse.redirect(`${baseUrl}/konto`), "error", message);
|
||||
|
||||
if (!token) {
|
||||
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||
@@ -34,7 +34,9 @@ export async function GET(request: Request) {
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/konto?message=${encodeURIComponent("Deine E-Mail-Adresse wurde bestätigt.")}`
|
||||
return withFlash(
|
||||
NextResponse.redirect(`${baseUrl}/konto`),
|
||||
"success",
|
||||
"Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen."
|
||||
);
|
||||
}
|
||||
@@ -69,3 +69,37 @@ p {
|
||||
background: var(--color-honey);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,6 @@ export default async function ProductDetailPage(
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const { slug } = await props.params;
|
||||
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
||||
const product = await getProductBySlug(slug);
|
||||
if (!product) notFound();
|
||||
|
||||
@@ -349,12 +348,6 @@ export default async function ProductDetailPage(
|
||||
</div>
|
||||
) : null}
|
||||
</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}
|
||||
</>
|
||||
@@ -438,12 +431,6 @@ export default async function ProductDetailPage(
|
||||
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
</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 ? (
|
||||
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
|
||||
|
||||
+14
-25
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { fail, succeed } from "@/lib/flash";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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_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
|
||||
// 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> {
|
||||
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();
|
||||
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({
|
||||
@@ -44,14 +42,14 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
comment: formData.get("comment"),
|
||||
});
|
||||
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({
|
||||
where: { id: parsed.data.productId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!product) fail("/shop", "Produkt nicht gefunden.");
|
||||
if (!product) return fail("/shop", "Produkt nicht gefunden.");
|
||||
|
||||
const redirectPath = `/honig/${product.slug}`;
|
||||
|
||||
@@ -59,12 +57,12 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
// bewerten ("verifizierter Kauf") — verhindert beliebige Fremdbewertungen.
|
||||
const purchased = await hasCustomerPurchased(parsed.data.productId, customer.id);
|
||||
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);
|
||||
if (existing) {
|
||||
fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
|
||||
return fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
|
||||
}
|
||||
|
||||
await prisma.review.create({
|
||||
@@ -77,13 +75,13 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
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. */
|
||||
export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
||||
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 submittedPath = String(formData.get("redirectPath") ?? "/shop");
|
||||
@@ -114,13 +112,6 @@ export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
||||
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
|
||||
@@ -130,14 +121,14 @@ function failNotify(path: string, message: string): never {
|
||||
export async function subscribeStockNotificationAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
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
|
||||
// Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber
|
||||
// unseren Absender anzuschreiben.
|
||||
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({
|
||||
@@ -145,14 +136,14 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
||||
email: formData.get("email"),
|
||||
});
|
||||
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({
|
||||
where: { id: parsed.data.productId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!product) failNotify("/shop", "Produkt nicht gefunden.");
|
||||
if (!product) return fail("/shop", "Produkt nicht gefunden.");
|
||||
|
||||
const redirectPath = `/honig/${product.slug}`;
|
||||
|
||||
@@ -165,7 +156,5 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
||||
update: { notifiedAt: null, locale },
|
||||
});
|
||||
|
||||
redirect(
|
||||
`${redirectPath}?notifyMessage=${encodeURIComponent("Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.")}`
|
||||
);
|
||||
return succeed(redirectPath, "Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.");
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
export { fail, succeed } from "@/lib/flash";
|
||||
|
||||
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
|
||||
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
@@ -8,6 +7,7 @@ import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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
|
||||
@@ -19,10 +19,10 @@ import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CO
|
||||
*/
|
||||
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||
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") {
|
||||
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();
|
||||
@@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
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 passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||
if (!passwordValid) {
|
||||
fail("/konto", "Passwort ist falsch.");
|
||||
return fail("/konto", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
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 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> {
|
||||
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({
|
||||
where: { id: customer.id },
|
||||
@@ -67,5 +67,5 @@ export async function logoutAllDevicesAction(): Promise<void> {
|
||||
});
|
||||
|
||||
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.");
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { addressSchema } from "@/lib/validation";
|
||||
import { fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
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({
|
||||
label: formData.get("label"),
|
||||
@@ -21,7 +22,7 @@ export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
isDefault: formData.get("isDefault") === "on",
|
||||
});
|
||||
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) {
|
||||
@@ -30,26 +31,29 @@ export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
|
||||
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
|
||||
revalidatePath("/konto/adressen");
|
||||
await setFlash("success", "Adresse gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteAddressAction(formData: FormData): Promise<void> {
|
||||
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") ?? "");
|
||||
await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } });
|
||||
revalidatePath("/konto/adressen");
|
||||
await setFlash("success", "Adresse gelöscht.");
|
||||
}
|
||||
|
||||
export async function setDefaultAddressAction(formData: FormData): Promise<void> {
|
||||
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 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.update({ where: { id: address.id }, data: { isDefault: true } });
|
||||
revalidatePath("/konto/adressen");
|
||||
await setFlash("success", "Standardadresse geändert.");
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import type { Customer } from "@/generated/prisma/client";
|
||||
import { fail } from "./_shared";
|
||||
import { isTurnstileValid } from "@/lib/turnstile";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||
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> {
|
||||
const ip = await getClientIp();
|
||||
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,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
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({
|
||||
@@ -51,23 +52,23 @@ export async function loginAction(formData: FormData): Promise<void> {
|
||||
password: formData.get("password"),
|
||||
});
|
||||
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
|
||||
// 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)) {
|
||||
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 } });
|
||||
|
||||
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()) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto",
|
||||
"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) 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) {
|
||||
@@ -102,24 +103,24 @@ export async function loginAction(formData: FormData): Promise<void> {
|
||||
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
const customerId = await getTwoFactorChallengeCustomerId();
|
||||
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();
|
||||
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,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
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 customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||
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 =
|
||||
@@ -152,7 +153,7 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -162,5 +163,5 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
await destroySession();
|
||||
redirect("/konto");
|
||||
return succeed("/konto", "Du wurdest abgemeldet.");
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function toggleNewsletterAction(): Promise<void> {
|
||||
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 } });
|
||||
|
||||
@@ -28,4 +29,5 @@ export async function toggleNewsletterAction(): Promise<void> {
|
||||
}
|
||||
|
||||
revalidatePath("/konto");
|
||||
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail } from "./_shared";
|
||||
import { isTurnstileValid } from "@/lib/turnstile";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
||||
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> {
|
||||
const ip = await getClientIp();
|
||||
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,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
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") });
|
||||
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 } });
|
||||
@@ -57,13 +58,13 @@ export async function requestPasswordResetAction(formData: FormData): Promise<vo
|
||||
export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
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,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
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({
|
||||
@@ -71,7 +72,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
password: formData.get("password"),
|
||||
});
|
||||
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({
|
||||
@@ -80,7 +81,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
|
||||
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
|
||||
if (!isValid) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"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
|
||||
// eigenstaendig umgehbar.
|
||||
if (targetCustomer?.blockedAt) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"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
|
||||
// Passwort versuchen kann, statt einen komplett neuen Reset-Link
|
||||
// anfordern zu muessen.
|
||||
redirect(
|
||||
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}&error=${encodeURIComponent(
|
||||
return fail(
|
||||
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}`,
|
||||
"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);
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert."));
|
||||
return succeed("/konto", "Dein Passwort wurde geändert.");
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { isPasswordBreached } from "@/lib/password-breach-check";
|
||||
import { fail } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
||||
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> {
|
||||
const ip = await getClientIp();
|
||||
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") ?? "");
|
||||
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({
|
||||
@@ -33,15 +34,15 @@ export async function registerAction(formData: FormData): Promise<void> {
|
||||
password: formData.get("password"),
|
||||
});
|
||||
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")) {
|
||||
fail("/konto", "Die Passwörter stimmen nicht überein.");
|
||||
return fail("/konto", "Die Passwörter stimmen nicht überein.");
|
||||
}
|
||||
|
||||
if (await isPasswordBreached(parsed.data.password)) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto",
|
||||
"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
|
||||
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
|
||||
await hashPassword(parsed.data.password);
|
||||
fail(
|
||||
return fail(
|
||||
"/konto",
|
||||
"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);
|
||||
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. */
|
||||
export async function resendVerificationEmailAction(): Promise<void> {
|
||||
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");
|
||||
|
||||
const ip = await getClientIp();
|
||||
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);
|
||||
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet."));
|
||||
const mailSent = await createAndSendVerificationEmail(customer);
|
||||
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.`);
|
||||
}
|
||||
@@ -19,13 +19,14 @@ import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function startTwoFactorSetupAction(): Promise<void> {
|
||||
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");
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
@@ -36,11 +37,11 @@ export async function startTwoFactorSetupAction(): Promise<void> {
|
||||
|
||||
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
|
||||
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();
|
||||
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();
|
||||
@@ -51,12 +52,12 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
|
||||
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();
|
||||
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();
|
||||
@@ -83,12 +84,14 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
|
||||
|
||||
export async function dismissBackupCodesAction(): Promise<void> {
|
||||
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> {
|
||||
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();
|
||||
if (
|
||||
@@ -98,13 +101,13 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
|
||||
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 passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||
if (!passwordValid) {
|
||||
fail("/konto/zwei-faktor", "Passwort ist falsch.");
|
||||
return fail("/konto/zwei-faktor", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
@@ -121,5 +124,5 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled");
|
||||
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.");
|
||||
}
|
||||
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export const metadata: Metadata = { title: "Meine Adressen" };
|
||||
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 { error } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
@@ -27,9 +26,6 @@ export default async function KontoAdressenPage(props: PageProps<"/konto/adresse
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<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">
|
||||
{addresses.map((address) => (
|
||||
|
||||
@@ -21,7 +21,6 @@ const RETURN_ELIGIBLE_STATUSES = [
|
||||
export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
@@ -55,9 +54,6 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
||||
))}
|
||||
</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">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { fail, succeed } from "@/lib/flash";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
@@ -17,13 +17,10 @@ const RETURN_ELIGIBLE_STATUSES = [
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
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({
|
||||
orderId: formData.get("orderId"),
|
||||
@@ -31,7 +28,7 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
if (!parsed.success) {
|
||||
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({
|
||||
@@ -40,13 +37,13 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
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)) {
|
||||
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) {
|
||||
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({
|
||||
@@ -54,7 +51,5 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/konto/bestellungen");
|
||||
redirect(
|
||||
`/konto/bestellungen?message=${encodeURIComponent("Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.")}`
|
||||
);
|
||||
return succeed("/konto/bestellungen", "Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.");
|
||||
}
|
||||
@@ -25,12 +25,11 @@ const RETURN_ELIGIBLE_STATUSES = [
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
|
||||
export default async function KontoBestellungenPage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
@@ -42,12 +41,6 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<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 ? (
|
||||
<AccountEmptyState
|
||||
|
||||
+1
-22
@@ -22,9 +22,8 @@ import {
|
||||
export const metadata: Metadata = { title: "Mein Konto" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
export default async function KontoPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
if (!customer) {
|
||||
@@ -32,16 +31,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<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">
|
||||
<form action={loginAction} className="space-y-4 md:pr-12">
|
||||
<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">
|
||||
<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 ? (
|
||||
<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">
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = { title: "Passwort vergessen" };
|
||||
|
||||
export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, sent } = await props.searchParams;
|
||||
const { sent } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
@@ -18,9 +18,6 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
||||
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
||||
<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 ? (
|
||||
<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">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { token, error } = await props.searchParams;
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
@@ -21,9 +21,6 @@ export default async function PasswortZuruecksetzenPage(
|
||||
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
||||
<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 ? (
|
||||
<p className="mt-6 text-sm text-ink/70">
|
||||
|
||||
@@ -11,9 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export const metadata: Metadata = { title: "Anmeldung bestätigen" };
|
||||
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 { error } = await props.searchParams;
|
||||
|
||||
const customerId = await getTwoFactorChallengeCustomerId();
|
||||
if (!customerId) redirect("/konto");
|
||||
@@ -28,9 +27,6 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f
|
||||
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">
|
||||
<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 dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorEinrichtenPage(
|
||||
props: PageProps<"/konto/zwei-faktor/einrichten">
|
||||
) {
|
||||
export default async function ZweiFaktorEinrichtenPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
@@ -35,9 +32,6 @@ export default async function ZweiFaktorEinrichtenPage(
|
||||
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">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */}
|
||||
|
||||
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" };
|
||||
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 { error, message } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
@@ -34,12 +33,6 @@ export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-fakto
|
||||
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 ? (
|
||||
<div className="mt-8 space-y-6">
|
||||
|
||||
+6
-1
@@ -4,6 +4,9 @@ import "./globals.css";
|
||||
import { Header, CartDrawer } from "@/components/site-chrome";
|
||||
import { Footer } from "@/components/site-footer";
|
||||
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 { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getSocialLinks } from "@/lib/social-links";
|
||||
@@ -43,7 +46,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const locale = await getLocale();
|
||||
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();
|
||||
|
||||
// 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} />
|
||||
<CartDrawer />
|
||||
<CookieConsentBanner />
|
||||
<FlashToast flash={flash} />
|
||||
<SubmitFeedback flashId={flash?.id ?? null} />
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { noticeUrl } from "@/lib/notice";
|
||||
import { noIndexPrivate } from "@/lib/seo";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Container } from "@/components/ui";
|
||||
@@ -20,7 +21,7 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
||||
const t = getDictionary(await getLocale());
|
||||
const { invoiceNumber } = await props.params;
|
||||
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);
|
||||
if (!invoice) notFound();
|
||||
|
||||
@@ -23,7 +23,12 @@ export function ConfirmForm({
|
||||
action={action}
|
||||
className={className}
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -927,6 +927,12 @@ export const de: Dictionary = {
|
||||
unknown: "Status unbekannt",
|
||||
},
|
||||
},
|
||||
flash: {
|
||||
close: "Meldung schließen",
|
||||
success: "Erledigt",
|
||||
error: "Fehler",
|
||||
info: "Hinweis",
|
||||
},
|
||||
adminArchive: {
|
||||
tabsAriaLabel: "Ansicht",
|
||||
tabActive: "Aktiv",
|
||||
|
||||
@@ -915,6 +915,12 @@ export const en: Dictionary = {
|
||||
unknown: "Status unknown",
|
||||
},
|
||||
},
|
||||
flash: {
|
||||
close: "Close message",
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
info: "Notice",
|
||||
},
|
||||
adminArchive: {
|
||||
tabsAriaLabel: "View",
|
||||
tabActive: "Active",
|
||||
|
||||
@@ -603,6 +603,12 @@ export interface Dictionary {
|
||||
unknown: string;
|
||||
};
|
||||
};
|
||||
flash: {
|
||||
close: string;
|
||||
success: string;
|
||||
error: string;
|
||||
info: string;
|
||||
};
|
||||
adminArchive: {
|
||||
tabsAriaLabel: string;
|
||||
tabActive: string;
|
||||
|
||||
@@ -16,7 +16,7 @@ export function hashVerificationToken(token: string): string {
|
||||
* Link. Wird nach der Selbst-Registrierung aufgerufen sowie ueber den
|
||||
* "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");
|
||||
await prisma.emailVerificationToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
|
||||
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 { subject, html, text } = await emailVerificationEmail(await getLocale(), verifyUrl);
|
||||
await sendMail({ to: customer.email, subject, html, text });
|
||||
return sendMail({ to: customer.email, subject, html, text });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -66,8 +66,14 @@ function getTransport() {
|
||||
* damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen.
|
||||
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung
|
||||
* 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 from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
||||
|
||||
@@ -75,7 +81,7 @@ export async function sendMail(input: SendMailInput): Promise<void> {
|
||||
console.warn(
|
||||
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -87,8 +93,10 @@ export async function sendMail(input: SendMailInput): Promise<void> {
|
||||
text: input.text,
|
||||
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
||||
});
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function hashResetToken(token: string): string {
|
||||
* und der Admin-Benutzerverwaltung (statt dass ein Admin dort direkt ein
|
||||
* 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");
|
||||
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
|
||||
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 { 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
@@ -1,3 +1,4 @@
|
||||
import { withFlash } from "@/lib/flash-core";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
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 }));
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/konto", request.url);
|
||||
loginUrl.searchParams.set(
|
||||
return withCsp(
|
||||
withFlash(
|
||||
NextResponse.redirect(new URL("/konto", request.url)),
|
||||
"error",
|
||||
session
|
||||
? "Dein Konto hat keinen Zugriff auf den Admin-Bereich."
|
||||
: "Bitte melde dich an, um den Admin-Bereich zu nutzen."
|
||||
)
|
||||
);
|
||||
return withCsp(NextResponse.redirect(loginUrl));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user