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
+762
-416
No files matched your search
@@ -1,4 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
import type { CustomerRole } from "@/generated/prisma/client";
|
import type { CustomerRole } from "@/generated/prisma/client";
|
||||||
@@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role:
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fail(path: string, message: string): never {
|
export { fail, succeed } from "@/lib/flash";
|
||||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
||||||
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import type { ArchiveKind } from "@/lib/archive";
|
import type { ArchiveKind } from "@/lib/archive";
|
||||||
import { requireAdmin, str } from "./_shared";
|
import { requireAdmin, str, fail } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
const LIST_PATH: Record<ArchiveKind, string> = {
|
const LIST_PATH: Record<ArchiveKind, string> = {
|
||||||
orders: "/admin/bestellungen",
|
orders: "/admin/bestellungen",
|
||||||
@@ -35,7 +36,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
|||||||
const kind = str(formData, "kind");
|
const kind = str(formData, "kind");
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
const archive = str(formData, "mode") === "archive";
|
const archive = str(formData, "mode") === "archive";
|
||||||
if (!isArchiveKind(kind) || !id) return;
|
if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage.");
|
||||||
|
|
||||||
const data = archive
|
const data = archive
|
||||||
? { archivedAt: new Date(), archivedManually: true }
|
? { archivedAt: new Date(), archivedManually: true }
|
||||||
@@ -51,7 +52,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
|||||||
: kind === "returns"
|
: kind === "returns"
|
||||||
? await prisma.returnRequest.updateMany({ where, data })
|
? await prisma.returnRequest.updateMany({ where, data })
|
||||||
: await prisma.product.updateMany({ where, data });
|
: await prisma.product.updateMany({ where, data });
|
||||||
if (result.count === 0) return;
|
if (result.count === 0) return fail(LIST_PATH[kind], "Eintrag nicht gefunden.");
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: archive ? "archive.manual_archive" : "archive.manual_restore",
|
action: archive ? "archive.manual_archive" : "archive.manual_restore",
|
||||||
@@ -60,4 +61,5 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(LIST_PATH[kind]);
|
revalidatePath(LIST_PATH[kind]);
|
||||||
|
await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt.");
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||||
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
||||||
@@ -70,5 +70,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
redirect("/admin/inhalte?saved=1");
|
return succeed("/admin/inhalte", "Inhalte gespeichert.");
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -10,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
|||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||||
|
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
|
|
||||||
export async function createCustomerAction(formData: FormData): Promise<void> {
|
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -21,12 +21,12 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
|||||||
role: str(formData, "role") || "KUNDE",
|
role: str(formData, "role") || "KUNDE",
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
return fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
||||||
@@ -43,7 +43,7 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await createAndSendPasswordResetEmail(customer);
|
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "customer.create",
|
action: "customer.create",
|
||||||
entityType: "Customer",
|
entityType: "Customer",
|
||||||
@@ -52,7 +52,13 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
redirect("/admin/benutzer");
|
if (!mailSent) {
|
||||||
|
return fail(
|
||||||
|
"/admin/benutzer",
|
||||||
|
`Konto ${customer.email} wurde angelegt, aber die Einladung zum Passwort-Setzen ging nicht raus. ${MAIL_FAILED_HINT} Danach auf der Benutzerseite erneut senden.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return succeed("/admin/benutzer", `Konto ${customer.email} wurde angelegt — die Einladung zum Passwort-Setzen ist verschickt.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCustomerAction(formData: FormData): Promise<void> {
|
export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -67,23 +73,23 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
|||||||
role: str(formData, "role") || "KUNDE",
|
role: str(formData, "role") || "KUNDE",
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === admin.id && parsed.data.role !== admin.role) {
|
if (id === admin.id && parsed.data.role !== admin.role) {
|
||||||
fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
return fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await prisma.customer.findUnique({ where: { id } });
|
const target = await prisma.customer.findUnique({ where: { id } });
|
||||||
if (!target) fail("/admin/benutzer", "Konto nicht gefunden.");
|
if (!target) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||||
|
|
||||||
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
|
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
|
||||||
fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
return fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||||
if (emailOwner && emailOwner.id !== id) {
|
if (emailOwner && emailOwner.id !== id) {
|
||||||
fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
return fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailChanged = parsed.data.email !== target.email;
|
const emailChanged = parsed.data.email !== target.email;
|
||||||
@@ -116,7 +122,7 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
redirect("/admin/benutzer");
|
return succeed("/admin/benutzer", "Änderungen gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function blockCustomerAction(formData: FormData): Promise<void> {
|
export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -124,10 +130,10 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
if (id === admin.id) {
|
if (id === admin.id) {
|
||||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
||||||
}
|
}
|
||||||
if (await isLastAdmin(id)) {
|
if (await isLastAdmin(id)) {
|
||||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
||||||
@@ -139,6 +145,7 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
|
await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -156,6 +163,7 @@ export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
|||||||
detail: unblocked.email,
|
detail: unblocked.email,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
|
await setFlash("success", `${unblocked.email} wurde entsperrt.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -163,16 +171,16 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
if (id === admin.id) {
|
if (id === admin.id) {
|
||||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
||||||
}
|
}
|
||||||
if (await isLastAdmin(id)) {
|
if (await isLastAdmin(id)) {
|
||||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = await prisma.customer.delete({ where: { id } });
|
const deleted = await prisma.customer.delete({ where: { id } });
|
||||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
redirect("/admin/benutzer");
|
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
||||||
@@ -181,14 +189,15 @@ export async function sendCustomerPasswordResetAction(formData: FormData): Promi
|
|||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id } });
|
const customer = await prisma.customer.findUnique({ where: { id } });
|
||||||
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
|
if (!customer) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||||
|
|
||||||
await createAndSendPasswordResetEmail(customer);
|
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "customer.password_reset_sent",
|
action: "customer.password_reset_sent",
|
||||||
entityType: "Customer",
|
entityType: "Customer",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
detail: customer.email,
|
detail: customer.email,
|
||||||
});
|
});
|
||||||
redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt."));
|
if (!mailSent) return fail(`/admin/benutzer/${id}`, MAIL_FAILED_HINT);
|
||||||
|
return succeed(`/admin/benutzer/${id}`, "Passwort-Reset-E-Mail wurde verschickt.");
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
|
|||||||
import { getCompanyInfo } from "@/lib/company-info";
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
||||||
import { requireAdmin, fail, str } from "./_shared";
|
import { requireAdmin, fail, str } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
|
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
|
||||||
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
|
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
|
||||||
@@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) {
|
if (!order) {
|
||||||
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.fulfillmentMethod !== "VERSAND") {
|
if (order.fulfillmentMethod !== "VERSAND") {
|
||||||
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||||
}
|
}
|
||||||
if (order.payment?.status !== "SUCCEEDED") {
|
if (order.payment?.status !== "SUCCEEDED") {
|
||||||
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||||
}
|
}
|
||||||
if (order.shipment?.trackingNumber) {
|
if (order.shipment?.trackingNumber) {
|
||||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
|
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
|
||||||
@@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
claimed = false;
|
claimed = false;
|
||||||
}
|
}
|
||||||
if (!claimed) {
|
if (!claimed) {
|
||||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
|
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
|
||||||
@@ -126,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (error instanceof DhlNotConfiguredError) {
|
if (error instanceof DhlNotConfiguredError) {
|
||||||
fail(detailPath, error.message);
|
return fail(detailPath, error.message);
|
||||||
}
|
}
|
||||||
if (error instanceof DhlApiError) {
|
if (error instanceof DhlApiError) {
|
||||||
fail(detailPath, error.message);
|
return fail(detailPath, error.message);
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -153,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
|
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
fail(
|
return fail(
|
||||||
detailPath,
|
detailPath,
|
||||||
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
|
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
|
||||||
);
|
);
|
||||||
@@ -168,4 +169,5 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/bestellungen");
|
revalidatePath("/admin/bestellungen");
|
||||||
revalidatePath(detailPath);
|
revalidatePath(detailPath);
|
||||||
|
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { discountCodeSchema } from "@/lib/validation";
|
import { discountCodeSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, str } from "./_shared";
|
import { requireAdmin, fail, str } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
function parseDiscountCodeForm(formData: FormData) {
|
function parseDiscountCodeForm(formData: FormData) {
|
||||||
const rawMax = str(formData, "maxUses");
|
const rawMax = str(formData, "maxUses");
|
||||||
@@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseDiscountCodeForm(formData);
|
const parsed = parseDiscountCodeForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
|
return fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await prisma.discountCode.create({ data: parsed.data });
|
const code = await prisma.discountCode.create({ data: parsed.data });
|
||||||
@@ -48,7 +48,7 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/gutscheine");
|
revalidatePath("/admin/gutscheine");
|
||||||
redirect("/admin/gutscheine");
|
return succeed("/admin/gutscheine", "Gutschein angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
||||||
@@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseDiscountCodeForm(formData);
|
const parsed = parseDiscountCodeForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||||
if (existing && existing.id !== id) {
|
if (existing && existing.id !== id) {
|
||||||
fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
|
return fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.discountCode.update({ where: { id }, data: parsed.data });
|
await prisma.discountCode.update({ where: { id }, data: parsed.data });
|
||||||
@@ -75,7 +75,7 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/gutscheine");
|
revalidatePath("/admin/gutscheine");
|
||||||
redirect("/admin/gutscheine");
|
return succeed("/admin/gutscheine", "Gutschein gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
||||||
@@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
try {
|
try {
|
||||||
deleted = await prisma.discountCode.delete({ where: { id } });
|
deleted = await prisma.discountCode.delete({ where: { id } });
|
||||||
} catch {
|
} catch {
|
||||||
fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
return fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
@@ -100,5 +100,5 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/gutscheine");
|
revalidatePath("/admin/gutscheine");
|
||||||
redirect("/admin/gutscheine");
|
return succeed("/admin/gutscheine", "Gutschein gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { isValidUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
|
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
|
||||||
@@ -55,7 +55,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Ernte angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||||
@@ -80,7 +80,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Ernte gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,10 +99,10 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: { _count: { select: { batches: true, products: true } } },
|
include: { _count: { select: { batches: true, products: true } } },
|
||||||
});
|
});
|
||||||
if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden.");
|
if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden.");
|
||||||
|
|
||||||
if (harvest._count.batches > 0 || harvest._count.products > 0) {
|
if (harvest._count.batches > 0 || harvest._count.products > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/ernten",
|
"/admin/ernten",
|
||||||
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
|
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
|
||||||
);
|
);
|
||||||
@@ -119,7 +119,7 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Ernte gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,7 +148,7 @@ export async function toggleHarvestPublishedAction(formData: FormData): Promise<
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBatchAction(formData: FormData): Promise<void> {
|
export async function createBatchAction(formData: FormData): Promise<void> {
|
||||||
@@ -169,7 +169,7 @@ export async function createBatchAction(formData: FormData): Promise<void> {
|
|||||||
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
||||||
|
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
redirect("/admin/chargen");
|
return succeed("/admin/chargen", "Charge angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBatchAction(formData: FormData): Promise<void> {
|
export async function updateBatchAction(formData: FormData): Promise<void> {
|
||||||
@@ -190,7 +190,7 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
|
|||||||
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
||||||
|
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
redirect("/admin/chargen");
|
return succeed("/admin/chargen", "Charge gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,16 +210,16 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: { _count: { select: { products: true, orderItems: true } } },
|
include: { _count: { select: { products: true, orderItems: true } } },
|
||||||
});
|
});
|
||||||
if (!batch) fail("/admin/chargen", "Charge nicht gefunden.");
|
if (!batch) return fail("/admin/chargen", "Charge nicht gefunden.");
|
||||||
|
|
||||||
if (batch._count.orderItems > 0) {
|
if (batch._count.orderItems > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/chargen",
|
"/admin/chargen",
|
||||||
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
|
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (batch._count.products > 0) {
|
if (batch._count.products > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/chargen",
|
"/admin/chargen",
|
||||||
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
|
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
|
||||||
);
|
);
|
||||||
@@ -230,5 +230,5 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
|
|||||||
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
|
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
|
||||||
|
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
redirect("/admin/chargen");
|
return succeed("/admin/chargen", "Charge gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { requireAdmin, str } from "./_shared";
|
import { requireAdmin, str, fail } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
const status = str(formData, "status");
|
const status = str(formData, "status");
|
||||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return;
|
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status.");
|
||||||
|
|
||||||
await prisma.businessInquiry.update({
|
await prisma.businessInquiry.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -22,6 +23,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detail: status,
|
detail: status,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/anfragen");
|
revalidatePath("/admin/anfragen");
|
||||||
|
await setFlash("success", "Status der Anfrage gespeichert.");
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { sendMail } from "@/lib/mail";
|
import { sendMail } from "@/lib/mail";
|
||||||
@@ -13,6 +12,7 @@ import { getStripeClient } from "@/lib/stripe";
|
|||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
||||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||||
|
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
|
|
||||||
const ORDER_STATUSES = [
|
const ORDER_STATUSES = [
|
||||||
"NEU",
|
"NEU",
|
||||||
@@ -124,7 +124,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||||
const detailPath = `/admin/bestellungen/${orderId}`;
|
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||||
if (!ORDER_STATUSES.includes(status)) {
|
if (!ORDER_STATUSES.includes(status)) {
|
||||||
fail(detailPath, "Ungültiger Status.");
|
return fail(detailPath, "Ungültiger Status.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
@@ -198,9 +198,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||||
|
let mailSent: boolean | undefined;
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "order.status_change",
|
action: "order.status_change",
|
||||||
@@ -238,7 +241,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
|
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
|
||||||
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
|
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
|
||||||
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
|
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
|
||||||
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +260,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
trackingNumber,
|
trackingNumber,
|
||||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||||
});
|
});
|
||||||
await sendMail({ to: result.email, subject, html, text });
|
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||||
}
|
}
|
||||||
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
||||||
const { subject, html, text } = await pickupReadyEmail({
|
const { subject, html, text } = await pickupReadyEmail({
|
||||||
@@ -265,11 +268,14 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
orderNumber: result.orderNumber,
|
orderNumber: result.orderNumber,
|
||||||
firstName: result.firstName,
|
firstName: result.firstName,
|
||||||
});
|
});
|
||||||
await sendMail({ to: result.email, subject, html, text });
|
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/bestellungen");
|
revalidatePath("/admin/bestellungen");
|
||||||
|
const done = `Status von ${result.orderNumber} gespeichert.`;
|
||||||
|
if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||||
|
await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -324,7 +330,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) {
|
if (!order) {
|
||||||
fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
|
return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const verdict = checkOrderDeletable({
|
const verdict = checkOrderDeletable({
|
||||||
@@ -333,7 +339,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
|||||||
hasInvoice: order.invoice !== null,
|
hasInvoice: order.invoice !== null,
|
||||||
});
|
});
|
||||||
if (!verdict.deletable) {
|
if (!verdict.deletable) {
|
||||||
fail(detailPath, {
|
return fail(detailPath, {
|
||||||
invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`,
|
invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`,
|
||||||
payment:
|
payment:
|
||||||
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.",
|
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.",
|
||||||
@@ -351,7 +357,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
|||||||
if (session.status === "open") {
|
if (session.status === "open") {
|
||||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||||
} else if (session.status === "complete") {
|
} else if (session.status === "complete") {
|
||||||
fail(
|
return fail(
|
||||||
detailPath,
|
detailPath,
|
||||||
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren."
|
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren."
|
||||||
);
|
);
|
||||||
@@ -365,7 +371,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
|||||||
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
|
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
fail(
|
return fail(
|
||||||
detailPath,
|
detailPath,
|
||||||
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen."
|
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen."
|
||||||
);
|
);
|
||||||
@@ -394,7 +400,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
fail(
|
return fail(
|
||||||
detailPath,
|
detailPath,
|
||||||
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden."
|
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden."
|
||||||
);
|
);
|
||||||
@@ -410,9 +416,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/bestellungen");
|
revalidatePath("/admin/bestellungen");
|
||||||
redirect(
|
return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
|
||||||
`/admin/bestellungen?message=${encodeURIComponent(`Bestellung ${order.orderNumber} wurde gelöscht.`)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,7 +437,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
|||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
||||||
});
|
});
|
||||||
if (!order) return;
|
if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||||
|
|
||||||
await prisma.shipment.upsert({
|
await prisma.shipment.upsert({
|
||||||
where: { orderId },
|
where: { orderId },
|
||||||
@@ -448,6 +452,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
|||||||
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mailSent: boolean | undefined;
|
||||||
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
||||||
const { subject, html, text } = await shipmentEmail({
|
const { subject, html, text } = await shipmentEmail({
|
||||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||||
@@ -457,9 +462,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
|||||||
trackingNumber,
|
trackingNumber,
|
||||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||||
});
|
});
|
||||||
await sendMail({ to: order.email, subject, html, text });
|
mailSent = await sendMail({ to: order.email, subject, html, text });
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/bestellungen");
|
revalidatePath("/admin/bestellungen");
|
||||||
revalidatePath(`/admin/bestellungen/${orderId}`);
|
revalidatePath(`/admin/bestellungen/${orderId}`);
|
||||||
|
if (mailSent === false) {
|
||||||
|
return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`);
|
||||||
|
}
|
||||||
|
await setFlash(
|
||||||
|
"success",
|
||||||
|
mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
@@ -8,6 +7,7 @@ import { notifyStockAvailable } from "@/lib/stock-notifications";
|
|||||||
import { adminProductNumbersSchema } from "@/lib/validation";
|
import { adminProductNumbersSchema } from "@/lib/validation";
|
||||||
import { isValidUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
|
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
|
||||||
@@ -18,9 +18,9 @@ import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
|||||||
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
|
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
|
||||||
* Produkt bestellbar.
|
* Produkt bestellbar.
|
||||||
*/
|
*/
|
||||||
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
|
async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise<void> {
|
||||||
if (isActive && priceCents <= 0) {
|
if (isActive && priceCents <= 0) {
|
||||||
fail(
|
return fail(
|
||||||
redirectPath,
|
redirectPath,
|
||||||
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
|
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
|
||||||
);
|
);
|
||||||
@@ -42,12 +42,12 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
vatRatePercent: str(formData, "vatRatePercent"),
|
vatRatePercent: str(formData, "vatRatePercent"),
|
||||||
});
|
});
|
||||||
if (!parsedNumbers.success) {
|
if (!parsedNumbers.success) {
|
||||||
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
const numbers = parsedNumbers.data;
|
const numbers = parsedNumbers.data;
|
||||||
|
|
||||||
const isActive = formData.get("isActive") === "on";
|
const isActive = formData.get("isActive") === "on";
|
||||||
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
||||||
|
|
||||||
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
||||||
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
||||||
@@ -64,7 +64,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
// hervorgeht, dass lediglich die Adresse belegt ist.
|
// hervorgeht, dass lediglich die Adresse belegt ist.
|
||||||
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
||||||
if (slugTaken) {
|
if (slugTaken) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/produkte/neu",
|
"/admin/produkte/neu",
|
||||||
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
|
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
|
||||||
);
|
);
|
||||||
@@ -101,7 +101,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect("/admin/produkte");
|
return succeed("/admin/produkte", "Produkt angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,10 +131,10 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: { _count: { select: { orderItems: true } } },
|
include: { _count: { select: { orderItems: true } } },
|
||||||
});
|
});
|
||||||
if (!product) fail("/admin/produkte", "Produkt nicht gefunden.");
|
if (!product) return fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||||
|
|
||||||
if (product._count.orderItems > 0) {
|
if (product._count.orderItems > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/produkte",
|
"/admin/produkte",
|
||||||
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
|
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
|
||||||
);
|
);
|
||||||
@@ -150,7 +150,7 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect("/admin/produkte");
|
return succeed("/admin/produkte", "Produkt gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProductAction(formData: FormData): Promise<void> {
|
export async function updateProductAction(formData: FormData): Promise<void> {
|
||||||
@@ -168,12 +168,12 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
vatRatePercent: str(formData, "vatRatePercent"),
|
vatRatePercent: str(formData, "vatRatePercent"),
|
||||||
});
|
});
|
||||||
if (!parsedNumbers.success) {
|
if (!parsedNumbers.success) {
|
||||||
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
const numbers = parsedNumbers.data;
|
const numbers = parsedNumbers.data;
|
||||||
|
|
||||||
const isActive = formData.get("isActive") === "on";
|
const isActive = formData.get("isActive") === "on";
|
||||||
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||||
|
|
||||||
const previousInventory = await prisma.inventory.findUnique({
|
const previousInventory = await prisma.inventory.findUnique({
|
||||||
where: { productId: id },
|
where: { productId: id },
|
||||||
@@ -216,5 +216,5 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect("/admin/produkte");
|
return succeed("/admin/produkte", "Produkt gespeichert.");
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { requireAdmin, str } from "./_shared";
|
import { requireAdmin, str } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
export async function approveReviewAction(formData: FormData): Promise<void> {
|
export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -24,6 +25,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/bewertungen");
|
revalidatePath("/admin/bewertungen");
|
||||||
revalidatePath(`/honig/${review.product.slug}`);
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||||
@@ -44,4 +46,5 @@ export async function deleteReviewAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/bewertungen");
|
revalidatePath("/admin/bewertungen");
|
||||||
revalidatePath(`/honig/${review.product.slug}`);
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
await setFlash("success", "Bewertung gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { shippingTierSchema } from "@/lib/validation";
|
import { shippingTierSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, str } from "./_shared";
|
import { requireAdmin, fail, str } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
function parseShippingTierForm(formData: FormData) {
|
function parseShippingTierForm(formData: FormData) {
|
||||||
const rawMax = str(formData, "maxQuantity");
|
const rawMax = str(formData, "maxQuantity");
|
||||||
@@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseShippingTierForm(formData);
|
const parsed = parseShippingTierForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = await prisma.shippingTier.create({
|
const tier = await prisma.shippingTier.create({
|
||||||
@@ -42,7 +42,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
|||||||
revalidatePath("/admin/versand");
|
revalidatePath("/admin/versand");
|
||||||
revalidatePath("/warenkorb");
|
revalidatePath("/warenkorb");
|
||||||
revalidatePath("/versand-zahlung");
|
revalidatePath("/versand-zahlung");
|
||||||
redirect("/admin/versand");
|
return succeed("/admin/versand", "Versandstufe angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
||||||
@@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseShippingTierForm(formData);
|
const parsed = parseShippingTierForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.shippingTier.update({
|
await prisma.shippingTier.update({
|
||||||
@@ -68,7 +68,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
|||||||
revalidatePath("/admin/versand");
|
revalidatePath("/admin/versand");
|
||||||
revalidatePath("/warenkorb");
|
revalidatePath("/warenkorb");
|
||||||
revalidatePath("/versand-zahlung");
|
revalidatePath("/versand-zahlung");
|
||||||
redirect("/admin/versand");
|
return succeed("/admin/versand", "Versandstufe gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
||||||
@@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise<void
|
|||||||
revalidatePath("/admin/versand");
|
revalidatePath("/admin/versand");
|
||||||
revalidatePath("/warenkorb");
|
revalidatePath("/warenkorb");
|
||||||
revalidatePath("/versand-zahlung");
|
revalidatePath("/versand-zahlung");
|
||||||
redirect("/admin/versand");
|
return succeed("/admin/versand", "Versandstufe gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
|
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error, message } = await props.searchParams;
|
|
||||||
|
|
||||||
const [admin, customer] = await Promise.all([
|
const [admin, customer] = await Promise.all([
|
||||||
getCurrentAdmin(),
|
getCurrentAdmin(),
|
||||||
@@ -36,12 +35,6 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p>
|
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { createCustomerAction } from "../../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) {
|
export default async function AdminBenutzerNewPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
@@ -13,9 +12,6 @@ export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benu
|
|||||||
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
|
||||||
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
|
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
|
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
|
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
|
||||||
const { error, message, q } = await props.searchParams;
|
const { q } = await props.searchParams;
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
|
||||||
const [admin, customers] = await Promise.all([
|
const [admin, customers] = await Promise.all([
|
||||||
@@ -42,12 +42,6 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const statusLabels = t.orderStatusBadge;
|
const statusLabels = t.orderStatusBadge;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error, message } = await props.searchParams;
|
|
||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -47,12 +46,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive
|
|||||||
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const statusLabels = t.orderStatusBadge;
|
const statusLabels = t.orderStatusBadge;
|
||||||
const { q, status, error, message, ansicht } = await props.searchParams;
|
const { q, status, ansicht } = await props.searchParams;
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
||||||
const isArchive = isArchiveView(ansicht);
|
const isArchive = isArchiveView(ansicht);
|
||||||
@@ -53,12 +53,6 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
|||||||
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
|
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
|
||||||
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
|
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
|
||||||
aus wie ein folgenloser Klick. */}
|
aus wie ein folgenloser Klick. */}
|
||||||
{message ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AdminArchiveTabs
|
<AdminArchiveTabs
|
||||||
basePath="/admin/bestellungen"
|
basePath="/admin/bestellungen"
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { createBatchAction, deleteBatchAction } from "../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminBatchesPage(props: PageProps<"/admin/chargen">) {
|
export default async function AdminBatchesPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const [batches, harvests] = await Promise.all([
|
const [batches, harvests] = await Promise.all([
|
||||||
prisma.batch.findMany({
|
prisma.batch.findMany({
|
||||||
orderBy: { fillDate: "desc" },
|
orderBy: { fillDate: "desc" },
|
||||||
@@ -22,9 +21,6 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
|
|||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const error = searchParams.error;
|
|
||||||
const harvests = await prisma.harvest.findMany({
|
const harvests = await prisma.harvest.findMany({
|
||||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||||
include: { _count: { select: { batches: true, products: true } } },
|
include: { _count: { select: { batches: true, products: true } } },
|
||||||
@@ -22,9 +21,6 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
<ApiCoreSyncResult searchParams={searchParams} />
|
<ApiCoreSyncResult searchParams={searchParams} />
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ function toDateInputValue(date: Date | null): string {
|
|||||||
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
|
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const code = await prisma.discountCode.findUnique({ where: { id } });
|
const code = await prisma.discountCode.findUnique({ where: { id } });
|
||||||
if (!code) notFound();
|
if (!code) notFound();
|
||||||
|
|
||||||
@@ -22,9 +21,6 @@ export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gu
|
|||||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
|
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<input type="hidden" name="id" value={code.id} />
|
<input type="hidden" name="id" value={code.id} />
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ import { createDiscountCodeAction } from "../../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminGutscheinNewPage(props: PageProps<"/admin/gutscheine/neu">) {
|
export default async function AdminGutscheinNewPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
|
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
|
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { deleteDiscountCodeAction } from "../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) {
|
export default async function AdminGutscheinePage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,9 +21,6 @@ export default async function AdminGutscheinePage(props: PageProps<"/admin/gutsc
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|||||||
@@ -197,10 +197,9 @@ function GroupForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
|
export default async function AdminInhaltePage() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const { saved } = await props.searchParams;
|
|
||||||
const current = await loadCurrentValues();
|
const current = await loadCurrentValues();
|
||||||
|
|
||||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||||
@@ -218,12 +217,6 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
|||||||
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
|
||||||
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
|
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
|
||||||
|
|
||||||
{saved ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminContentPage.saved}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
|
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
|
||||||
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
|
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { noticeUrl } from "@/lib/notice";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
import { logoutAction } from "@/app/konto/actions";
|
import { logoutAction } from "@/app/konto/actions";
|
||||||
import { AdminShell } from "@/components/admin-shell";
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
@@ -30,7 +31,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
|
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
|
||||||
const admin = await getCurrentAdmin();
|
const admin = await getCurrentAdmin();
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
redirect("/konto?error=" + encodeURIComponent("Bitte melde dich mit einem Admin-Konto an."));
|
redirect(noticeUrl("admin-login"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
|
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
|
||||||
@@ -38,10 +39,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
|
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
|
||||||
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
|
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
|
||||||
if (!admin.twoFactorEnabled) {
|
if (!admin.twoFactorEnabled) {
|
||||||
redirect(
|
redirect(noticeUrl("admin-2fa"));
|
||||||
"/konto/zwei-faktor?error=" +
|
|
||||||
encodeURIComponent("Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
|
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const rawProduct = await prisma.product.findUnique({
|
const rawProduct = await prisma.product.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { harvest: true, batch: true, inventory: true },
|
include: { harvest: true, batch: true, inventory: true },
|
||||||
@@ -36,12 +35,6 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</p>
|
</p>
|
||||||
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
|
|
||||||
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
|
|
||||||
wieder auf dem Formular und wusste nicht, was abgelehnt wurde. */}
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<input type="hidden" name="id" value={product.id} />
|
<input type="hidden" name="id" value={product.id} />
|
||||||
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { createProductAction } from "../../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminProductNewPage(props: PageProps<"/admin/produkte/neu">) {
|
export default async function AdminProductNewPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const [harvests, batches] = await Promise.all([
|
const [harvests, batches] = await Promise.all([
|
||||||
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||||
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
|
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
|
||||||
@@ -17,12 +16,6 @@ export default async function AdminProductNewPage(props: PageProps<"/admin/produ
|
|||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
|
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
|
||||||
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
|
|
||||||
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
|
|
||||||
wieder auf dem leeren Formular und wusste nicht, was fehlte. */}
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
|
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
|
||||||
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
|
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { fail, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireAdmin } from "@/app/admin/actions";
|
import { requireAdmin } from "@/app/admin/actions";
|
||||||
@@ -15,15 +15,12 @@ function str(formData: FormData, key: string): string {
|
|||||||
return String(formData.get(key) ?? "").trim();
|
return String(formData.get(key) ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(path: string, message: string): never {
|
|
||||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> {
|
export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
const status = str(formData, "status");
|
const status = str(formData, "status");
|
||||||
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return;
|
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return fail("/admin/retouren", "Ungültiger Status.");
|
||||||
|
|
||||||
const returnRequest = await prisma.returnRequest.findUnique({
|
const returnRequest = await prisma.returnRequest.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -31,7 +28,9 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
|
|||||||
});
|
});
|
||||||
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
|
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
|
||||||
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
|
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
|
||||||
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") return;
|
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") {
|
||||||
|
return fail("/admin/retouren", "Diese Retoure wurde bereits entschieden oder existiert nicht mehr.");
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.returnRequest.update({
|
const updated = await prisma.returnRequest.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -51,9 +50,12 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
|
|||||||
status === "ANGENOMMEN"
|
status === "ANGENOMMEN"
|
||||||
? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName })
|
? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName })
|
||||||
: await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName });
|
: await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName });
|
||||||
await sendMail({ to: updated.order.email, subject, html, text });
|
const mailSent = await sendMail({ to: updated.order.email, subject, html, text });
|
||||||
|
|
||||||
revalidatePath("/admin/retouren");
|
revalidatePath("/admin/retouren");
|
||||||
|
const done = `Retoure ${updated.order.orderNumber} ${status === "ANGENOMMEN" ? "angenommen" : "abgelehnt"}.`;
|
||||||
|
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||||
|
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
|
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
|
||||||
@@ -68,11 +70,13 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
|
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") {
|
||||||
|
return fail("/admin/retouren", "Diese Retoure kann nicht (mehr) erstattet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
||||||
if (!paymentIntentId) {
|
if (!paymentIntentId) {
|
||||||
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
return fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
|
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
|
||||||
@@ -87,7 +91,7 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
where: { id, status: "ANGENOMMEN" },
|
where: { id, status: "ANGENOMMEN" },
|
||||||
data: { status: "ERSTATTET" },
|
data: { status: "ERSTATTET" },
|
||||||
});
|
});
|
||||||
if (claimed.count === 0) return;
|
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient();
|
||||||
try {
|
try {
|
||||||
@@ -116,7 +120,10 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
orderNumber: returnRequest.order.orderNumber,
|
orderNumber: returnRequest.order.orderNumber,
|
||||||
firstName: returnRequest.order.firstName,
|
firstName: returnRequest.order.firstName,
|
||||||
});
|
});
|
||||||
await sendMail({ to: returnRequest.order.email, subject, html, text });
|
const mailSent = await sendMail({ to: returnRequest.order.email, subject, html, text });
|
||||||
|
|
||||||
revalidatePath("/admin/retouren");
|
revalidatePath("/admin/retouren");
|
||||||
|
const done = `Retoure ${returnRequest.order.orderNumber} erstattet — der Betrag geht über Stripe zurück.`;
|
||||||
|
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||||
|
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
|
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const tier = await prisma.shippingTier.findUnique({ where: { id } });
|
const tier = await prisma.shippingTier.findUnique({ where: { id } });
|
||||||
if (!tier) notFound();
|
if (!tier) notFound();
|
||||||
|
|
||||||
@@ -17,9 +16,6 @@ export default async function AdminVersandEditPage(props: PageProps<"/admin/vers
|
|||||||
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
|
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<input type="hidden" name="id" value={tier.id} />
|
<input type="hidden" name="id" value={tier.id} />
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ import { createShippingTierAction } from "../../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminVersandNewPage(props: PageProps<"/admin/versand/neu">) {
|
export default async function AdminVersandNewPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
|
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { deleteShippingTierAction } from "../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminVersandPage(props: PageProps<"/admin/versand">) {
|
export default async function AdminVersandPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,9 +23,6 @@ export default async function AdminVersandPage(props: PageProps<"/admin/versand"
|
|||||||
|
|
||||||
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
|
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withFlash } from "@/lib/flash";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
import { getBaseUrl } from "@/lib/url";
|
||||||
import {
|
import {
|
||||||
@@ -50,9 +51,7 @@ export async function GET(request: Request) {
|
|||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
function redirectWithError(message: string): NextResponse {
|
function redirectWithError(message: string): NextResponse {
|
||||||
const url = new URL("/konto", baseUrl);
|
return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message);
|
||||||
url.searchParams.set("error", message);
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEntraConfigured()) {
|
if (!isEntraConfigured()) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withFlash } from "@/lib/flash";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
import { getBaseUrl } from "@/lib/url";
|
||||||
import { setEntraOAuthCookie } from "@/lib/auth";
|
import { setEntraOAuthCookie } from "@/lib/auth";
|
||||||
import { signEntraOAuthState } from "@/lib/session";
|
import { signEntraOAuthState } from "@/lib/session";
|
||||||
@@ -12,9 +13,11 @@ import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOA
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!isEntraConfigured()) {
|
if (!isEntraConfigured()) {
|
||||||
const url = new URL("/konto", getBaseUrl());
|
return withFlash(
|
||||||
url.searchParams.set("error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an.");
|
NextResponse.redirect(new URL("/konto", getBaseUrl())),
|
||||||
return NextResponse.redirect(url);
|
"error",
|
||||||
|
"SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { codeVerifier, codeChallenge } = generatePkcePair();
|
const { codeVerifier, codeChallenge } = generatePkcePair();
|
||||||
|
|||||||
@@ -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 { prisma } from "@/lib/prisma";
|
||||||
import { hashVerificationToken } from "@/lib/email-verification";
|
import { hashVerificationToken } from "@/lib/email-verification";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { withFlash } from "@/lib/flash";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const token = new URL(request.url).searchParams.get("token");
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
const fail = (message: string) =>
|
const fail = (message: string) => withFlash(NextResponse.redirect(`${baseUrl}/konto`), "error", message);
|
||||||
NextResponse.redirect(`${baseUrl}/konto?error=${encodeURIComponent(message)}`);
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return fail("Dieser Bestätigungslink ist ungültig.");
|
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||||
@@ -34,7 +34,9 @@ export async function GET(request: Request) {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.redirect(
|
return withFlash(
|
||||||
`${baseUrl}/konto?message=${encodeURIComponent("Deine E-Mail-Adresse wurde bestätigt.")}`
|
NextResponse.redirect(`${baseUrl}/konto`),
|
||||||
|
"success",
|
||||||
|
"Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,3 +69,37 @@ p {
|
|||||||
background: var(--color-honey);
|
background: var(--color-honey);
|
||||||
color: var(--color-cream);
|
color: var(--color-cream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Knopf eines gerade laufenden Server-Action-Formulars (components/
|
||||||
|
submit-feedback.tsx): sichtbar "arbeitet", gegen Doppelklick gesperrt. */
|
||||||
|
[data-pending="true"] {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-pending="true"]::after {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.8em;
|
||||||
|
height: 0.8em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
vertical-align: -0.1em;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
animation: wh-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wh-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-pending="true"]::after {
|
||||||
|
animation: none;
|
||||||
|
border-right-color: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,6 @@ export default async function ProductDetailPage(
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const { slug } = await props.params;
|
const { slug } = await props.params;
|
||||||
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
|
||||||
const product = await getProductBySlug(slug);
|
const product = await getProductBySlug(slug);
|
||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
|
|
||||||
@@ -349,12 +348,6 @@ export default async function ProductDetailPage(
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
{notifyError ? (
|
|
||||||
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
|
||||||
) : null}
|
|
||||||
{notifyMessage ? (
|
|
||||||
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
@@ -438,12 +431,6 @@ export default async function ProductDetailPage(
|
|||||||
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mt-4 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{reviews.length === 0 ? (
|
{reviews.length === 0 ? (
|
||||||
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
|
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
|
||||||
|
|||||||
+14
-25
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { fail, succeed } from "@/lib/flash";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
@@ -16,9 +17,6 @@ const REVIEW_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
const STOCK_NOTIFY_ATTEMPTS_PER_WINDOW = 5;
|
const STOCK_NOTIFY_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const STOCK_NOTIFY_WINDOW_MS = 60 * 60 * 1000;
|
const STOCK_NOTIFY_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
function fail(path: string, message: string): never {
|
|
||||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// redirectPath kommt aus einem Formularfeld (siehe toggleWishlistAction) und
|
// redirectPath kommt aus einem Formularfeld (siehe toggleWishlistAction) und
|
||||||
// darf deshalb nicht ungeprueft an redirect()/revalidatePath() durchgereicht
|
// darf deshalb nicht ungeprueft an redirect()/revalidatePath() durchgereicht
|
||||||
@@ -31,11 +29,11 @@ function isSafeRedirectPath(path: string): boolean {
|
|||||||
|
|
||||||
export async function submitReviewAction(formData: FormData): Promise<void> {
|
export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an, um eine Bewertung abzugeben.");
|
if (!customer) return fail("/konto", "Bitte melde dich an, um eine Bewertung abzugeben.");
|
||||||
|
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`review:${ip}`, REVIEW_ATTEMPTS_PER_WINDOW, REVIEW_WINDOW_MS)) {
|
if (!rateLimit(`review:${ip}`, REVIEW_ATTEMPTS_PER_WINDOW, REVIEW_WINDOW_MS)) {
|
||||||
fail("/shop", "Zu viele Bewertungen. Bitte versuche es später erneut.");
|
return fail("/shop", "Zu viele Bewertungen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = reviewSchema.safeParse({
|
const parsed = reviewSchema.safeParse({
|
||||||
@@ -44,14 +42,14 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
|||||||
comment: formData.get("comment"),
|
comment: formData.get("comment"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = await prisma.product.findUnique({
|
const product = await prisma.product.findUnique({
|
||||||
where: { id: parsed.data.productId },
|
where: { id: parsed.data.productId },
|
||||||
select: { slug: true },
|
select: { slug: true },
|
||||||
});
|
});
|
||||||
if (!product) fail("/shop", "Produkt nicht gefunden.");
|
if (!product) return fail("/shop", "Produkt nicht gefunden.");
|
||||||
|
|
||||||
const redirectPath = `/honig/${product.slug}`;
|
const redirectPath = `/honig/${product.slug}`;
|
||||||
|
|
||||||
@@ -59,12 +57,12 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
|||||||
// bewerten ("verifizierter Kauf") — verhindert beliebige Fremdbewertungen.
|
// bewerten ("verifizierter Kauf") — verhindert beliebige Fremdbewertungen.
|
||||||
const purchased = await hasCustomerPurchased(parsed.data.productId, customer.id);
|
const purchased = await hasCustomerPurchased(parsed.data.productId, customer.id);
|
||||||
if (!purchased) {
|
if (!purchased) {
|
||||||
fail(redirectPath, "Nur Käufer:innen dieses Produkts können eine Bewertung abgeben.");
|
return fail(redirectPath, "Nur Käufer:innen dieses Produkts können eine Bewertung abgeben.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await getCustomerReview(parsed.data.productId, customer.id);
|
const existing = await getCustomerReview(parsed.data.productId, customer.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
|
return fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.review.create({
|
await prisma.review.create({
|
||||||
@@ -77,13 +75,13 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(redirectPath);
|
revalidatePath(redirectPath);
|
||||||
redirect(`${redirectPath}?message=${encodeURIComponent("Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht.")}`);
|
return succeed(redirectPath, "Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuegt ein Produkt der Wunschliste hinzu bzw. entfernt es, je nach aktuellem Zustand. */
|
/** Fuegt ein Produkt der Wunschliste hinzu bzw. entfernt es, je nach aktuellem Zustand. */
|
||||||
export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an, um Produkte zu merken.");
|
if (!customer) return fail("/konto", "Bitte melde dich an, um Produkte zu merken.");
|
||||||
|
|
||||||
const productId = String(formData.get("productId") ?? "");
|
const productId = String(formData.get("productId") ?? "");
|
||||||
const submittedPath = String(formData.get("redirectPath") ?? "/shop");
|
const submittedPath = String(formData.get("redirectPath") ?? "/shop");
|
||||||
@@ -114,13 +112,6 @@ export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
|||||||
redirect(redirectPath);
|
redirect(redirectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eigene Query-Parameter statt der von submitReviewAction genutzten
|
|
||||||
// error/message: beide Actions redirecten auf dieselbe Produktseite, ein
|
|
||||||
// gemeinsamer Parametername wuerde sonst z.B. eine Bewertungsbestaetigung
|
|
||||||
// faelschlich auch im Ausverkauft-Block der Seite anzeigen lassen.
|
|
||||||
function failNotify(path: string, message: string): never {
|
|
||||||
redirect(`${path}?notifyError=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meldet eine E-Mail-Adresse fuer die Wiederverfuegbarkeits-Benachrichtigung
|
* Meldet eine E-Mail-Adresse fuer die Wiederverfuegbarkeits-Benachrichtigung
|
||||||
@@ -130,14 +121,14 @@ function failNotify(path: string, message: string): never {
|
|||||||
export async function subscribeStockNotificationAction(formData: FormData): Promise<void> {
|
export async function subscribeStockNotificationAction(formData: FormData): Promise<void> {
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`stock-notify:${ip}`, STOCK_NOTIFY_ATTEMPTS_PER_WINDOW, STOCK_NOTIFY_WINDOW_MS)) {
|
if (!rateLimit(`stock-notify:${ip}`, STOCK_NOTIFY_ATTEMPTS_PER_WINDOW, STOCK_NOTIFY_WINDOW_MS)) {
|
||||||
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
return fail("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter
|
// Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter
|
||||||
// Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber
|
// Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber
|
||||||
// unseren Absender anzuschreiben.
|
// unseren Absender anzuschreiben.
|
||||||
if (!(await isTurnstileValid(formData, ip))) {
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
failNotify("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
return fail("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = stockNotificationSchema.safeParse({
|
const parsed = stockNotificationSchema.safeParse({
|
||||||
@@ -145,14 +136,14 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
|||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
failNotify("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = await prisma.product.findUnique({
|
const product = await prisma.product.findUnique({
|
||||||
where: { id: parsed.data.productId },
|
where: { id: parsed.data.productId },
|
||||||
select: { slug: true },
|
select: { slug: true },
|
||||||
});
|
});
|
||||||
if (!product) failNotify("/shop", "Produkt nicht gefunden.");
|
if (!product) return fail("/shop", "Produkt nicht gefunden.");
|
||||||
|
|
||||||
const redirectPath = `/honig/${product.slug}`;
|
const redirectPath = `/honig/${product.slug}`;
|
||||||
|
|
||||||
@@ -165,7 +156,5 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
|||||||
update: { notifiedAt: null, locale },
|
update: { notifiedAt: null, locale },
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect(
|
return succeed(redirectPath, "Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.");
|
||||||
`${redirectPath}?notifyMessage=${encodeURIComponent("Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.")}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
export { fail, succeed } from "@/lib/flash";
|
||||||
|
|
||||||
export function fail(path: string, message: string): never {
|
|
||||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
|
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
|
||||||
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
|
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
@@ -8,6 +7,7 @@ import { sendMail } from "@/lib/mail";
|
|||||||
import { securityAlertEmail } from "@/lib/email-templates";
|
import { securityAlertEmail } from "@/lib/email-templates";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
||||||
@@ -19,10 +19,10 @@ import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CO
|
|||||||
*/
|
*/
|
||||||
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
if (customer.role === "ADMIN") {
|
if (customer.role === "ADMIN") {
|
||||||
fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
|
return fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
@@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
|||||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
|
return fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = String(formData.get("password") ?? "");
|
const password = String(formData.get("password") ?? "");
|
||||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||||
if (!passwordValid) {
|
if (!passwordValid) {
|
||||||
fail("/konto", "Passwort ist falsch.");
|
return fail("/konto", "Passwort ist falsch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||||
@@ -47,7 +47,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
|||||||
|
|
||||||
await prisma.customer.delete({ where: { id: customer.id } });
|
await prisma.customer.delete({ where: { id: customer.id } });
|
||||||
await destroySession();
|
await destroySession();
|
||||||
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht."));
|
return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +59,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
|||||||
*/
|
*/
|
||||||
export async function logoutAllDevicesAction(): Promise<void> {
|
export async function logoutAllDevicesAction(): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
await prisma.customer.update({
|
await prisma.customer.update({
|
||||||
where: { id: customer.id },
|
where: { id: customer.id },
|
||||||
@@ -67,5 +67,5 @@ export async function logoutAllDevicesAction(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await destroySession();
|
await destroySession();
|
||||||
redirect("/konto?message=" + encodeURIComponent("Alle Geräte wurden abgemeldet. Bitte melde dich erneut an."));
|
return succeed("/konto", "Alle Geräte wurden abgemeldet. Bitte melde dich erneut an.");
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,11 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { addressSchema } from "@/lib/validation";
|
import { addressSchema } from "@/lib/validation";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
export async function addAddressAction(formData: FormData): Promise<void> {
|
export async function addAddressAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
const parsed = addressSchema.safeParse({
|
const parsed = addressSchema.safeParse({
|
||||||
label: formData.get("label"),
|
label: formData.get("label"),
|
||||||
@@ -21,7 +22,7 @@ export async function addAddressAction(formData: FormData): Promise<void> {
|
|||||||
isDefault: formData.get("isDefault") === "on",
|
isDefault: formData.get("isDefault") === "on",
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.isDefault) {
|
if (parsed.data.isDefault) {
|
||||||
@@ -30,26 +31,29 @@ export async function addAddressAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
|
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
|
||||||
revalidatePath("/konto/adressen");
|
revalidatePath("/konto/adressen");
|
||||||
|
await setFlash("success", "Adresse gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAddressAction(formData: FormData): Promise<void> {
|
export async function deleteAddressAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
const addressId = String(formData.get("addressId") ?? "");
|
const addressId = String(formData.get("addressId") ?? "");
|
||||||
await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } });
|
await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } });
|
||||||
revalidatePath("/konto/adressen");
|
revalidatePath("/konto/adressen");
|
||||||
|
await setFlash("success", "Adresse gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDefaultAddressAction(formData: FormData): Promise<void> {
|
export async function setDefaultAddressAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
const addressId = String(formData.get("addressId") ?? "");
|
const addressId = String(formData.get("addressId") ?? "");
|
||||||
const address = await prisma.address.findFirst({ where: { id: addressId, customerId: customer.id } });
|
const address = await prisma.address.findFirst({ where: { id: addressId, customerId: customer.id } });
|
||||||
if (!address) fail("/konto/adressen", "Adresse nicht gefunden.");
|
if (!address) return fail("/konto/adressen", "Adresse nicht gefunden.");
|
||||||
|
|
||||||
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
|
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
|
||||||
await prisma.address.update({ where: { id: address.id }, data: { isDefault: true } });
|
await prisma.address.update({ where: { id: address.id }, data: { isDefault: true } });
|
||||||
revalidatePath("/konto/adressen");
|
revalidatePath("/konto/adressen");
|
||||||
|
await setFlash("success", "Standardadresse geändert.");
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
|||||||
import type { Customer } from "@/generated/prisma/client";
|
import type { Customer } from "@/generated/prisma/client";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
import { isTurnstileValid } from "@/lib/turnstile";
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
@@ -37,13 +38,13 @@ async function registerFailedLogin(customer: Pick<Customer, "id" | "failedLoginA
|
|||||||
export async function loginAction(formData: FormData): Promise<void> {
|
export async function loginAction(formData: FormData): Promise<void> {
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
|
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
|
||||||
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
return fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
if (!(await isTurnstileValid(formData, ip))) {
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse({
|
const parsed = loginSchema.safeParse({
|
||||||
@@ -51,23 +52,23 @@ export async function loginAction(formData: FormData): Promise<void> {
|
|||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/konto", "Bitte E-Mail und Passwort angeben.");
|
return fail("/konto", "Bitte E-Mail und Passwort angeben.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
|
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
|
||||||
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
|
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
|
||||||
if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) {
|
if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) {
|
||||||
fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
|
return fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||||
|
|
||||||
if (customer?.blockedAt) {
|
if (customer?.blockedAt) {
|
||||||
fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
|
return fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
|
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
|
||||||
fail(
|
return fail(
|
||||||
"/konto",
|
"/konto",
|
||||||
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
|
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
|
||||||
);
|
);
|
||||||
@@ -79,7 +80,7 @@ export async function loginAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
if (!customer || !passwordValid) {
|
if (!customer || !passwordValid) {
|
||||||
if (customer) await registerFailedLogin(customer);
|
if (customer) await registerFailedLogin(customer);
|
||||||
fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
|
return fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
|
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
|
||||||
@@ -102,24 +103,24 @@ export async function loginAction(formData: FormData): Promise<void> {
|
|||||||
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||||
const customerId = await getTwoFactorChallengeCustomerId();
|
const customerId = await getTwoFactorChallengeCustomerId();
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`2fa-verify:${ip}:${customerId}`, TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW, TWO_FACTOR_VERIFY_WINDOW_MS)) {
|
if (!rateLimit(`2fa-verify:${ip}:${customerId}`, TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW, TWO_FACTOR_VERIFY_WINDOW_MS)) {
|
||||||
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
return fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
if (!(await isTurnstileValid(formData, ip))) {
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
return fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = String(formData.get("code") ?? "").trim();
|
const code = String(formData.get("code") ?? "").trim();
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
||||||
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const totpStep =
|
const totpStep =
|
||||||
@@ -152,7 +153,7 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
|
return fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearTwoFactorChallengeCookie();
|
await clearTwoFactorChallengeCookie();
|
||||||
@@ -162,5 +163,5 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
export async function logoutAction(): Promise<void> {
|
export async function logoutAction(): Promise<void> {
|
||||||
await destroySession();
|
await destroySession();
|
||||||
redirect("/konto");
|
return succeed("/konto", "Du wurdest abgemeldet.");
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
export async function toggleNewsletterAction(): Promise<void> {
|
export async function toggleNewsletterAction(): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } });
|
const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } });
|
||||||
|
|
||||||
@@ -28,4 +29,5 @@ export async function toggleNewsletterAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/konto");
|
revalidatePath("/konto");
|
||||||
|
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import { securityAlertEmail } from "@/lib/email-templates";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
import { isTurnstileValid } from "@/lib/turnstile";
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
||||||
@@ -26,18 +27,18 @@ const MIN_RESET_REQUEST_RESPONSE_MS = 400;
|
|||||||
export async function requestPasswordResetAction(formData: FormData): Promise<void> {
|
export async function requestPasswordResetAction(formData: FormData): Promise<void> {
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) {
|
if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) {
|
||||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
if (!(await isTurnstileValid(formData, ip))) {
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
return fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||||
@@ -57,13 +58,13 @@ export async function requestPasswordResetAction(formData: FormData): Promise<vo
|
|||||||
export async function resetPasswordAction(formData: FormData): Promise<void> {
|
export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) {
|
if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) {
|
||||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
if (!(await isTurnstileValid(formData, ip))) {
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = resetPasswordSchema.safeParse({
|
const parsed = resetPasswordSchema.safeParse({
|
||||||
@@ -71,7 +72,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
|||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||||
@@ -80,7 +81,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
|
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
fail(
|
return fail(
|
||||||
"/konto/passwort-vergessen",
|
"/konto/passwort-vergessen",
|
||||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||||
);
|
);
|
||||||
@@ -92,7 +93,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
|||||||
// handlungsfaehig machen — sonst waere die Sperre per Reset-Link
|
// handlungsfaehig machen — sonst waere die Sperre per Reset-Link
|
||||||
// eigenstaendig umgehbar.
|
// eigenstaendig umgehbar.
|
||||||
if (targetCustomer?.blockedAt) {
|
if (targetCustomer?.blockedAt) {
|
||||||
fail(
|
return fail(
|
||||||
"/konto/passwort-vergessen",
|
"/konto/passwort-vergessen",
|
||||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||||
);
|
);
|
||||||
@@ -103,10 +104,9 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
|||||||
// Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes
|
// Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes
|
||||||
// Passwort versuchen kann, statt einen komplett neuen Reset-Link
|
// Passwort versuchen kann, statt einen komplett neuen Reset-Link
|
||||||
// anfordern zu muessen.
|
// anfordern zu muessen.
|
||||||
redirect(
|
return fail(
|
||||||
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}&error=${encodeURIComponent(
|
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}`,
|
||||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,5 +147,5 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion);
|
await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion);
|
||||||
redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert."));
|
return succeed("/konto", "Dein Passwort wurde geändert.");
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
|||||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||||
import { isPasswordBreached } from "@/lib/password-breach-check";
|
import { isPasswordBreached } from "@/lib/password-breach-check";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
||||||
@@ -18,12 +19,12 @@ const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
export async function registerAction(formData: FormData): Promise<void> {
|
export async function registerAction(formData: FormData): Promise<void> {
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
|
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
|
||||||
fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
|
return fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const turnstileToken = String(formData.get("turnstileToken") ?? "");
|
const turnstileToken = String(formData.get("turnstileToken") ?? "");
|
||||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||||
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = registerSchema.safeParse({
|
const parsed = registerSchema.safeParse({
|
||||||
@@ -33,15 +34,15 @@ export async function registerAction(formData: FormData): Promise<void> {
|
|||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.get("password") !== formData.get("passwordConfirm")) {
|
if (formData.get("password") !== formData.get("passwordConfirm")) {
|
||||||
fail("/konto", "Die Passwörter stimmen nicht überein.");
|
return fail("/konto", "Die Passwörter stimmen nicht überein.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await isPasswordBreached(parsed.data.password)) {
|
if (await isPasswordBreached(parsed.data.password)) {
|
||||||
fail(
|
return fail(
|
||||||
"/konto",
|
"/konto",
|
||||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
||||||
);
|
);
|
||||||
@@ -54,7 +55,7 @@ export async function registerAction(formData: FormData): Promise<void> {
|
|||||||
// beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind
|
// beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind
|
||||||
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
|
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
|
||||||
await hashPassword(parsed.data.password);
|
await hashPassword(parsed.data.password);
|
||||||
fail(
|
return fail(
|
||||||
"/konto",
|
"/konto",
|
||||||
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
|
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
|
||||||
);
|
);
|
||||||
@@ -72,22 +73,37 @@ export async function registerAction(formData: FormData): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await createAndSendVerificationEmail(customer);
|
const mailSent = await createAndSendVerificationEmail(customer);
|
||||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||||
redirect("/konto");
|
if (!mailSent) {
|
||||||
|
return fail(
|
||||||
|
"/konto",
|
||||||
|
"Konto erstellt — aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte nutze gleich „Link erneut senden“."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return succeed(
|
||||||
|
"/konto",
|
||||||
|
`Willkommen! Bitte bestätige deine E-Mail-Adresse über den Link, den wir an ${customer.email} geschickt haben.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */
|
/** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */
|
||||||
export async function resendVerificationEmailAction(): Promise<void> {
|
export async function resendVerificationEmailAction(): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
if (customer.emailVerifiedAt) redirect("/konto");
|
if (customer.emailVerifiedAt) redirect("/konto");
|
||||||
|
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) {
|
if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) {
|
||||||
fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
return fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await createAndSendVerificationEmail(customer);
|
const mailSent = await createAndSendVerificationEmail(customer);
|
||||||
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet."));
|
if (!mailSent) {
|
||||||
|
return fail(
|
||||||
|
"/konto",
|
||||||
|
"Die E-Mail konnte gerade nicht versendet werden. Bitte versuche es in ein paar Minuten erneut oder schreib uns an [email protected]."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return succeed("/konto", `Wir haben dir einen neuen Bestätigungslink an ${customer.email} geschickt.`);
|
||||||
}
|
}
|
||||||
@@ -19,13 +19,14 @@ import { sendMail } from "@/lib/mail";
|
|||||||
import { securityAlertEmail } from "@/lib/email-templates";
|
import { securityAlertEmail } from "@/lib/email-templates";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
const TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
|
const TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
|
||||||
const TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS = 10 * 60 * 1000;
|
const TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
export async function startTwoFactorSetupAction(): Promise<void> {
|
export async function startTwoFactorSetupAction(): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
if (customer.twoFactorEnabled) redirect("/konto/zwei-faktor");
|
if (customer.twoFactorEnabled) redirect("/konto/zwei-faktor");
|
||||||
|
|
||||||
const secret = generateTotpSecret();
|
const secret = generateTotpSecret();
|
||||||
@@ -36,11 +37,11 @@ export async function startTwoFactorSetupAction(): Promise<void> {
|
|||||||
|
|
||||||
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
|
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
const setup = await getTwoFactorSetup();
|
const setup = await getTwoFactorSetup();
|
||||||
if (!setup || setup.customerId !== customer.id) {
|
if (!setup || setup.customerId !== customer.id) {
|
||||||
fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
|
return fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
@@ -51,12 +52,12 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
|
|||||||
TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS
|
TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
|
return fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = String(formData.get("code") ?? "").trim();
|
const code = String(formData.get("code") ?? "").trim();
|
||||||
if (!verifyTotpCode(setup.secret, code, customer.email)) {
|
if (!verifyTotpCode(setup.secret, code, customer.email)) {
|
||||||
fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
|
return fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { plain, hashes } = generateBackupCodes();
|
const { plain, hashes } = generateBackupCodes();
|
||||||
@@ -83,12 +84,14 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
|
|||||||
|
|
||||||
export async function dismissBackupCodesAction(): Promise<void> {
|
export async function dismissBackupCodesAction(): Promise<void> {
|
||||||
await clearTwoFactorBackupCodesDisplayCookie();
|
await clearTwoFactorBackupCodesDisplayCookie();
|
||||||
redirect("/konto/zwei-faktor");
|
// Zurueck ins Profil statt auf die 2FA-Seite: dort steht als einzige
|
||||||
|
// Aktion "deaktivieren" — direkt nach dem Einrichten die falsche Einladung.
|
||||||
|
return succeed("/konto", "Zwei-Faktor-Authentifizierung ist jetzt aktiv. Dein Konto ist zusätzlich geschützt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableTwoFactorAction(formData: FormData): Promise<void> {
|
export async function disableTwoFactorAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (
|
if (
|
||||||
@@ -98,13 +101,13 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
|
|||||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
|
return fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = String(formData.get("password") ?? "");
|
const password = String(formData.get("password") ?? "");
|
||||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||||
if (!passwordValid) {
|
if (!passwordValid) {
|
||||||
fail("/konto/zwei-faktor", "Passwort ist falsch.");
|
return fail("/konto/zwei-faktor", "Passwort ist falsch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
@@ -121,5 +124,5 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
|
|||||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled");
|
const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled");
|
||||||
await sendMail({ to: updated.email, subject, html, text });
|
await sendMail({ to: updated.email, subject, html, text });
|
||||||
|
|
||||||
redirect("/konto/zwei-faktor?message=" + encodeURIComponent("Zwei-Faktor-Authentifizierung wurde deaktiviert."));
|
return succeed("/konto", "Zwei-Faktor-Authentifizierung wurde deaktiviert.");
|
||||||
}
|
}
|
||||||
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export const metadata: Metadata = { title: "Meine Adressen" };
|
export const metadata: Metadata = { title: "Meine Adressen" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function KontoAdressenPage(props: PageProps<"/konto/adressen">) {
|
export default async function KontoAdressenPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) redirect("/konto");
|
if (!customer) redirect("/konto");
|
||||||
|
|
||||||
@@ -27,9 +26,6 @@ export default async function KontoAdressenPage(props: PageProps<"/konto/adresse
|
|||||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-10 space-y-4">
|
<div className="mt-10 space-y-4">
|
||||||
{addresses.map((address) => (
|
{addresses.map((address) => (
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const RETURN_ELIGIBLE_STATUSES = [
|
|||||||
export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) {
|
export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) redirect("/konto");
|
if (!customer) redirect("/konto");
|
||||||
|
|
||||||
@@ -55,9 +54,6 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={requestReturnAction} className="mt-6 space-y-4">
|
<form action={requestReturnAction} className="mt-6 space-y-4">
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { fail, succeed } from "@/lib/flash";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
@@ -17,13 +17,10 @@ const RETURN_ELIGIBLE_STATUSES = [
|
|||||||
"ABGEHOLT",
|
"ABGEHOLT",
|
||||||
];
|
];
|
||||||
|
|
||||||
function fail(path: string, message: string): never {
|
|
||||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestReturnAction(formData: FormData): Promise<void> {
|
export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
|
if (!customer) return fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
|
||||||
|
|
||||||
const parsed = returnRequestSchema.safeParse({
|
const parsed = returnRequestSchema.safeParse({
|
||||||
orderId: formData.get("orderId"),
|
orderId: formData.get("orderId"),
|
||||||
@@ -31,7 +28,7 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const orderId = String(formData.get("orderId") ?? "");
|
const orderId = String(formData.get("orderId") ?? "");
|
||||||
fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
@@ -40,13 +37,13 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!order || order.customerId !== customer.id) {
|
if (!order || order.customerId !== customer.id) {
|
||||||
fail("/konto/bestellungen", "Bestellung nicht gefunden.");
|
return fail("/konto/bestellungen", "Bestellung nicht gefunden.");
|
||||||
}
|
}
|
||||||
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status)) {
|
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status)) {
|
||||||
fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
|
return fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
|
||||||
}
|
}
|
||||||
if (order.returnRequest) {
|
if (order.returnRequest) {
|
||||||
fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
|
return fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.returnRequest.create({
|
await prisma.returnRequest.create({
|
||||||
@@ -54,7 +51,5 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/konto/bestellungen");
|
revalidatePath("/konto/bestellungen");
|
||||||
redirect(
|
return succeed("/konto/bestellungen", "Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.");
|
||||||
`/konto/bestellungen?message=${encodeURIComponent("Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.")}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -25,12 +25,11 @@ const RETURN_ELIGIBLE_STATUSES = [
|
|||||||
"ABGEHOLT",
|
"ABGEHOLT",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
|
export default async function KontoBestellungenPage() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const statusLabels = t.orderStatusBadge;
|
const statusLabels = t.orderStatusBadge;
|
||||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||||
const { error, message } = await props.searchParams;
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) redirect("/konto");
|
if (!customer) redirect("/konto");
|
||||||
|
|
||||||
@@ -42,12 +41,6 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
|||||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{orders.length === 0 ? (
|
{orders.length === 0 ? (
|
||||||
<AccountEmptyState
|
<AccountEmptyState
|
||||||
|
|||||||
+1
-22
@@ -22,9 +22,8 @@ import {
|
|||||||
export const metadata: Metadata = { title: "Mein Konto" };
|
export const metadata: Metadata = { title: "Mein Konto" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function KontoPage(props: PageProps<"/konto">) {
|
export default async function KontoPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error, message } = await props.searchParams;
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -32,16 +31,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
|||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-3xl">
|
<Container className="max-w-3xl">
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
||||||
<form action={loginAction} className="space-y-4 md:pr-12">
|
<form action={loginAction} className="space-y-4 md:pr-12">
|
||||||
<h2 className="text-lg text-forest">{t.account.login}</h2>
|
<h2 className="text-lg text-forest">{t.account.login}</h2>
|
||||||
@@ -134,16 +123,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
|||||||
<Container className="max-w-2xl">
|
<Container className="max-w-2xl">
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!customer.emailVerifiedAt ? (
|
{!customer.emailVerifiedAt ? (
|
||||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3">
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = { title: "Passwort vergessen" };
|
|||||||
|
|
||||||
export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) {
|
export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error, sent } = await props.searchParams;
|
const { sent } = await props.searchParams;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
@@ -18,9 +18,6 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
|||||||
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<p className="mt-6 text-sm text-ink/70 leading-relaxed">
|
<p className="mt-6 text-sm text-ink/70 leading-relaxed">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default async function PasswortZuruecksetzenPage(
|
|||||||
props: PageProps<"/konto/passwort-zuruecksetzen">
|
props: PageProps<"/konto/passwort-zuruecksetzen">
|
||||||
) {
|
) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { token, error } = await props.searchParams;
|
const { token } = await props.searchParams;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
@@ -21,9 +21,6 @@ export default async function PasswortZuruecksetzenPage(
|
|||||||
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!token ? (
|
{!token ? (
|
||||||
<p className="mt-6 text-sm text-ink/70">
|
<p className="mt-6 text-sm text-ink/70">
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export const metadata: Metadata = { title: "Anmeldung bestätigen" };
|
export const metadata: Metadata = { title: "Anmeldung bestätigen" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-faktor-code">) {
|
export default async function ZweiFaktorCodePage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
|
|
||||||
const customerId = await getTwoFactorChallengeCustomerId();
|
const customerId = await getTwoFactorChallengeCustomerId();
|
||||||
if (!customerId) redirect("/konto");
|
if (!customerId) redirect("/konto");
|
||||||
@@ -28,9 +27,6 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f
|
|||||||
subtitle={t.twoFactorCodePage.subtitle}
|
subtitle={t.twoFactorCodePage.subtitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={verifyTwoFactorAction} className="mt-6 space-y-4">
|
<form action={verifyTwoFactorAction} className="mt-6 space-y-4">
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
|
|||||||
@@ -11,11 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung einrichten" };
|
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung einrichten" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ZweiFaktorEinrichtenPage(
|
export default async function ZweiFaktorEinrichtenPage() {
|
||||||
props: PageProps<"/konto/zwei-faktor/einrichten">
|
|
||||||
) {
|
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) redirect("/konto");
|
if (!customer) redirect("/konto");
|
||||||
@@ -35,9 +32,6 @@ export default async function ZweiFaktorEinrichtenPage(
|
|||||||
subtitle={t.twoFactorSetupPage.subtitle}
|
subtitle={t.twoFactorSetupPage.subtitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-8 border border-ink/10 p-6 flex flex-col items-center">
|
<div className="mt-8 border border-ink/10 p-6 flex flex-col items-center">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */}
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" };
|
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-faktor">) {
|
export default async function ZweiFaktorPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error, message } = await props.searchParams;
|
|
||||||
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) redirect("/konto");
|
if (!customer) redirect("/konto");
|
||||||
@@ -34,12 +33,6 @@ export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-fakto
|
|||||||
subtitle={t.twoFactorPage.subtitle}
|
subtitle={t.twoFactorPage.subtitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{customer.twoFactorEnabled ? (
|
{customer.twoFactorEnabled ? (
|
||||||
<div className="mt-8 space-y-6">
|
<div className="mt-8 space-y-6">
|
||||||
|
|||||||
+6
-1
@@ -4,6 +4,9 @@ import "./globals.css";
|
|||||||
import { Header, CartDrawer } from "@/components/site-chrome";
|
import { Header, CartDrawer } from "@/components/site-chrome";
|
||||||
import { Footer } from "@/components/site-footer";
|
import { Footer } from "@/components/site-footer";
|
||||||
import { CookieConsentBanner } from "@/components/cookie-consent";
|
import { CookieConsentBanner } from "@/components/cookie-consent";
|
||||||
|
import { FlashToast } from "@/components/flash-toast";
|
||||||
|
import { SubmitFeedback } from "@/components/submit-feedback";
|
||||||
|
import { readFlash } from "@/lib/flash";
|
||||||
import { getSiteUrl } from "@/lib/url";
|
import { getSiteUrl } from "@/lib/url";
|
||||||
import { getCompanyInfo } from "@/lib/company-info";
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
import { getSocialLinks } from "@/lib/social-links";
|
import { getSocialLinks } from "@/lib/social-links";
|
||||||
@@ -43,7 +46,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const dict = getDictionary(locale);
|
const dict = getDictionary(locale);
|
||||||
const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]);
|
const [company, socialLinks, flash] = await Promise.all([getCompanyInfo(), getSocialLinks(), readFlash()]);
|
||||||
const base = getSiteUrl();
|
const base = getSiteUrl();
|
||||||
|
|
||||||
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
|
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
|
||||||
@@ -133,6 +136,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
<Footer companyInfo={company} socialLinks={socialLinks} />
|
<Footer companyInfo={company} socialLinks={socialLinks} />
|
||||||
<CartDrawer />
|
<CartDrawer />
|
||||||
<CookieConsentBanner />
|
<CookieConsentBanner />
|
||||||
|
<FlashToast flash={flash} />
|
||||||
|
<SubmitFeedback flashId={flash?.id ?? null} />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { noticeUrl } from "@/lib/notice";
|
||||||
import { noIndexPrivate } from "@/lib/seo";
|
import { noIndexPrivate } from "@/lib/seo";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { Container } from "@/components/ui";
|
import { Container } from "@/components/ui";
|
||||||
@@ -20,7 +21,7 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
|||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { invoiceNumber } = await props.params;
|
const { invoiceNumber } = await props.params;
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) redirect(`/konto?error=${encodeURIComponent(t.invoicePage.loginRequiredError)}`);
|
if (!customer) redirect(noticeUrl("invoice-login"));
|
||||||
|
|
||||||
const invoice = await getInvoiceForCustomer(invoiceNumber, customer.id);
|
const invoice = await getInvoiceForCustomer(invoiceNumber, customer.id);
|
||||||
if (!invoice) notFound();
|
if (!invoice) notFound();
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export function ConfirmForm({
|
|||||||
action={action}
|
action={action}
|
||||||
className={className}
|
className={className}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
if (!confirm(confirmMessage)) e.preventDefault();
|
if (!confirm(confirmMessage)) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Fuer SubmitFeedback: dieser Klick loest keine Aktion aus, der
|
||||||
|
// Knopf darf also nicht als "laeuft" markiert werden.
|
||||||
|
e.currentTarget.dataset.submitCancelled = "true";
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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",
|
unknown: "Status unbekannt",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
flash: {
|
||||||
|
close: "Meldung schließen",
|
||||||
|
success: "Erledigt",
|
||||||
|
error: "Fehler",
|
||||||
|
info: "Hinweis",
|
||||||
|
},
|
||||||
adminArchive: {
|
adminArchive: {
|
||||||
tabsAriaLabel: "Ansicht",
|
tabsAriaLabel: "Ansicht",
|
||||||
tabActive: "Aktiv",
|
tabActive: "Aktiv",
|
||||||
|
|||||||
@@ -915,6 +915,12 @@ export const en: Dictionary = {
|
|||||||
unknown: "Status unknown",
|
unknown: "Status unknown",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
flash: {
|
||||||
|
close: "Close message",
|
||||||
|
success: "Done",
|
||||||
|
error: "Error",
|
||||||
|
info: "Notice",
|
||||||
|
},
|
||||||
adminArchive: {
|
adminArchive: {
|
||||||
tabsAriaLabel: "View",
|
tabsAriaLabel: "View",
|
||||||
tabActive: "Active",
|
tabActive: "Active",
|
||||||
|
|||||||
@@ -603,6 +603,12 @@ export interface Dictionary {
|
|||||||
unknown: string;
|
unknown: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
flash: {
|
||||||
|
close: string;
|
||||||
|
success: string;
|
||||||
|
error: string;
|
||||||
|
info: string;
|
||||||
|
};
|
||||||
adminArchive: {
|
adminArchive: {
|
||||||
tabsAriaLabel: string;
|
tabsAriaLabel: string;
|
||||||
tabActive: string;
|
tabActive: string;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function hashVerificationToken(token: string): string {
|
|||||||
* Link. Wird nach der Selbst-Registrierung aufgerufen sowie ueber den
|
* Link. Wird nach der Selbst-Registrierung aufgerufen sowie ueber den
|
||||||
* "Link erneut senden"-Button auf /konto, solange das Konto unbestaetigt ist.
|
* "Link erneut senden"-Button auf /konto, solange das Konto unbestaetigt ist.
|
||||||
*/
|
*/
|
||||||
export async function createAndSendVerificationEmail(customer: { id: string; email: string }): Promise<void> {
|
export async function createAndSendVerificationEmail(customer: { id: string; email: string }): Promise<boolean> {
|
||||||
const rawToken = randomBytes(32).toString("hex");
|
const rawToken = randomBytes(32).toString("hex");
|
||||||
await prisma.emailVerificationToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
|
await prisma.emailVerificationToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
|
||||||
await prisma.emailVerificationToken.create({
|
await prisma.emailVerificationToken.create({
|
||||||
@@ -29,5 +29,5 @@ export async function createAndSendVerificationEmail(customer: { id: string; ema
|
|||||||
|
|
||||||
const verifyUrl = `${getBaseUrl()}/api/konto/email-bestaetigen?token=${rawToken}`;
|
const verifyUrl = `${getBaseUrl()}/api/konto/email-bestaetigen?token=${rawToken}`;
|
||||||
const { subject, html, text } = await emailVerificationEmail(await getLocale(), verifyUrl);
|
const { subject, html, text } = await emailVerificationEmail(await getLocale(), verifyUrl);
|
||||||
await sendMail({ to: customer.email, subject, html, text });
|
return sendMail({ to: customer.email, subject, html, text });
|
||||||
}
|
}
|
||||||
@@ -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.
|
* damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen.
|
||||||
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung
|
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung
|
||||||
* oder Registrierung nicht verhindern.
|
* oder Registrierung nicht verhindern.
|
||||||
|
*
|
||||||
|
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
|
||||||
|
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
|
||||||
|
* sichtbarer Rueckmeldung ("Link erneut gesendet", "Kundschaft informiert")
|
||||||
|
* duerfen Erfolg nur melden, wenn hier true zurueckkam — sonst wartet die
|
||||||
|
* Kundschaft auf eine Mail, die nie kommt.
|
||||||
*/
|
*/
|
||||||
export async function sendMail(input: SendMailInput): Promise<void> {
|
export async function sendMail(input: SendMailInput): Promise<boolean> {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
||||||
|
|
||||||
@@ -75,7 +81,7 @@ export async function sendMail(input: SendMailInput): Promise<void> {
|
|||||||
console.warn(
|
console.warn(
|
||||||
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
|
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -87,8 +93,10 @@ export async function sendMail(input: SendMailInput): Promise<void> {
|
|||||||
text: input.text,
|
text: input.text,
|
||||||
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
|
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
* und der Admin-Benutzerverwaltung (statt dass ein Admin dort direkt ein
|
||||||
* neues Passwort setzen kann, bekommt der Kunde immer nur diesen Link).
|
* neues Passwort setzen kann, bekommt der Kunde immer nur diesen Link).
|
||||||
*/
|
*/
|
||||||
export async function createAndSendPasswordResetEmail(customer: { id: string; email: string }): Promise<void> {
|
export async function createAndSendPasswordResetEmail(customer: { id: string; email: string }): Promise<boolean> {
|
||||||
const rawToken = randomBytes(32).toString("hex");
|
const rawToken = randomBytes(32).toString("hex");
|
||||||
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
|
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
|
||||||
await prisma.passwordResetToken.create({
|
await prisma.passwordResetToken.create({
|
||||||
@@ -30,5 +30,5 @@ export async function createAndSendPasswordResetEmail(customer: { id: string; em
|
|||||||
|
|
||||||
const resetUrl = `${getBaseUrl()}/konto/passwort-zuruecksetzen?token=${rawToken}`;
|
const resetUrl = `${getBaseUrl()}/konto/passwort-zuruecksetzen?token=${rawToken}`;
|
||||||
const { subject, html, text } = await passwordResetEmail(await getLocale(), resetUrl);
|
const { subject, html, text } = await passwordResetEmail(await getLocale(), resetUrl);
|
||||||
await sendMail({ to: customer.email, subject, html, text });
|
return sendMail({ to: customer.email, subject, html, text });
|
||||||
}
|
}
|
||||||
+9
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import { withFlash } from "@/lib/flash-core";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { SESSION_COOKIE, verifySessionToken } from "@/lib/session";
|
import { SESSION_COOKIE, verifySessionToken } from "@/lib/session";
|
||||||
@@ -65,14 +66,15 @@ export async function proxy(request: NextRequest) {
|
|||||||
return withCsp(new NextResponse("Zugriff verweigert.", { status: 403 }));
|
return withCsp(new NextResponse("Zugriff verweigert.", { status: 403 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginUrl = new URL("/konto", request.url);
|
return withCsp(
|
||||||
loginUrl.searchParams.set(
|
withFlash(
|
||||||
"error",
|
NextResponse.redirect(new URL("/konto", request.url)),
|
||||||
session
|
"error",
|
||||||
? "Dein Konto hat keinen Zugriff auf den Admin-Bereich."
|
session
|
||||||
: "Bitte melde dich an, um den Admin-Bereich zu nutzen."
|
? "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