From 4d3100e9ba7b71574dbbe00c5857543edc017c0e Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 3 Sep 2026 13:51:50 +0200 Subject: [PATCH] feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung --- .env.example | 77 ++++++++++++ k8s/01-configmap.yaml | 2 + k8s/02-secret.example.yaml | 20 +++ pnpm-lock.yaml | 43 +------ pnpm-workspace.yaml | 7 ++ prisma/schema.prisma | 6 + src/app/admin/actions.ts | 2 + src/app/admin/actions/harvests-batches.ts | 39 ++++++ src/app/admin/bestellungen/[id]/page.tsx | 32 ++++- src/app/admin/ernten/[id]/page.tsx | 21 +++- src/app/admin/ernten/page.tsx | 82 ++++++++++--- src/app/admin/inhalte/page.tsx | 1 + src/app/api/business-inquiry/route.ts | 6 + src/app/api/checkout/route.ts | 9 ++ src/app/api/newsletter/route.ts | 6 + src/app/impressum/page.tsx | 7 +- src/app/konto/actions/account.ts | 16 ++- src/app/konto/page.tsx | 29 +++++ src/app/layout.tsx | 5 +- src/components/site-chrome.tsx | 107 ++++++++++++++-- src/i18n/dictionaries/de.ts | 140 ++++++++++++++++++--- src/i18n/dictionaries/en.ts | 142 ++++++++++++++++++---- src/i18n/dictionary-type.ts | 27 +++- src/lib/auth.ts | 32 +++++ src/lib/content-registry.ts | 8 +- src/lib/data.ts | 11 +- src/lib/session.ts | 41 +++++++ src/lib/site-config.ts | 5 +- 28 files changed, 802 insertions(+), 121 deletions(-) diff --git a/.env.example b/.env.example index 2aa126c..2d5569e 100644 --- a/.env.example +++ b/.env.example @@ -80,3 +80,80 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_FROM="Wabenhain " + +# ApiCore-Ernten-Synchronisierung (optional, admin/ernten -> "ApiCore- +# Synchronisierung"). Ohne diese drei Werte zeigt der Sync-Button einen +# Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein separates +# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key- +# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten, +# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen +# "harvests:read" (und optional "apiaries:read") eintragen, nicht die eines +# echten Admin-Zugangs. Details siehe src/lib/apicore-client.ts. +APICORE_API_URL= +APICORE_SYNC_EMAIL= +APICORE_SYNC_PASSWORD= + +# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) — +# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im +# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt +# der Button dort nur den Hinweis "DHL ist noch nicht konfiguriert". +# +# So kommt ihr an die Werte: +# 1. Account anlegen auf https://developer.dhl.com und eine App registrieren, +# die die API "Parcel DE Shipping" (Post & Parcel Germany) einbindet. +# Die App liefert DHL_API_KEY (Consumer Key) und DHL_API_SECRET +# (Consumer Secret). Fuer den Sandbox-Test reichen diese beiden Werte +# plus die DHL-Testzugangsdaten unten. +# 2. Fuer den PRODUKTIV-Betrieb zusaetzlich einen DHL-Geschaeftskundenvertrag +# ("DHL Paket" Versandvertrag) abschliessen — Ansprechpartner ist der +# DHL-Vertrieb bzw. https://www.dhl.de/de/geschaeftskunden/paket.html. +# Darueber bekommt ihr: +# - Zugangsdaten zum Geschaeftskundenportal (GKP) -> DHL_GKP_USERNAME/ +# DHL_GKP_PASSWORD (dieselben Zugangsdaten, mit denen ihr euch auf +# dhl.de/geschaeftskundenportal einloggt). +# - Die EKP (10-stellige Kundennummer) plus Verfahren/Teilnahme fuer +# das gebuchte Produkt (Standard-Inlandspaket V01PAK) -> zusammen +# ergibt das die 14-stellige DHL_ACCOUNT_NUMBER (billingNumber). +# 3. DHL_ENVIRONMENT auf "sandbox" lassen, bis alles funktioniert, dann auf +# "production" umstellen (unterschiedliche Basis-URLs, siehe lib/dhl.ts). +# +# DHL-Sandbox-Testzugangsdaten (Stand der Recherche, koennen sich aendern — +# im Zweifel im DHL-Developer-Portal unter der App nachschauen): +# DHL_GKP_USERNAME=user-valid +# DHL_GKP_PASSWORD=SandboxPasswort2023! +DHL_API_KEY= +DHL_API_SECRET= +DHL_GKP_USERNAME= +DHL_GKP_PASSWORD= +# 14-stellig: EKP (10-stellig) + Verfahren (2-stellig) + Teilnahme (2-stellig) +DHL_ACCOUNT_NUMBER= +DHL_ENVIRONMENT=sandbox + +# Microsoft Entra ID SSO ("Mit Microsoft anmelden", siehe +# src/app/api/auth/entra/). Ohne diese vier Werte zeigt der Button auf +# /konto einen Hinweis, dass SSO noch nicht konfiguriert ist, statt +# abzustuerzen. +# +# So richtet ihr es ein (Owner-Aufgabe im Azure-Portal, https://entra.microsoft.com): +# 1. Entra ID -> App registrations -> "New registration". +# - Name frei waehlbar, z.B. "Wabenhain Kundenlogin". +# - "Supported account types": i.d.R. "Accounts in this organizational +# directory only" (Single Tenant) fuer eine reine Firmen-SSO-Anbindung. +# - Redirect URI: Plattform "Web", URL = /api/auth/entra/callback +# (lokal: http://localhost:3000/api/auth/entra/callback). +# 2. Nach dem Erstellen: "Application (client) ID" -> ENTRA_CLIENT_ID, +# "Directory (tenant) ID" -> ENTRA_TENANT_ID (beide auf der Overview-Seite +# der App-Registrierung sichtbar). +# 3. "Certificates & secrets" -> "New client secret" erzeugen -> der +# angezeigte WERT (nicht die Secret-ID!) -> ENTRA_CLIENT_SECRET. Nur +# einmal sichtbar, bei Verlust ein neues Secret erzeugen. +# 4. ENTRA_REDIRECT_URI = exakt dieselbe URL wie die in Schritt 1 hinterlegte +# Redirect-URI (muss zeichengenau uebereinstimmen). +# 5. "Enterprise applications" in Entra ID zeigt die App danach automatisch +# als Unternehmensapplikation (wird beim App-Registration-Schritt +# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis +# (z.B. nur bestimmte Gruppen) einschraenken. +ENTRA_TENANT_ID= +ENTRA_CLIENT_ID= +ENTRA_CLIENT_SECRET= +ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index bc23114..238503c 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -21,3 +21,5 @@ data: GOOGLE_PLACES_API_KEY: "" GOOGLE_PLACE_ID: "" NEXT_PUBLIC_TURNSTILE_SITE_KEY: "" + # "sandbox" bis DHL-Versandmarken getestet sind, danach "production". + DHL_ENVIRONMENT: "sandbox" diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index d0cedb5..38b66fe 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -46,3 +46,23 @@ stringData: SMTP_USER: "" SMTP_PASSWORD: "" TURNSTILE_SECRET_KEY: "" + # DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe + # .env.example fuer die vollstaendige Einrichtungsanleitung. Leer lassen, + # bis vorhanden — der Button zeigt dann "noch nicht konfiguriert" statt + # zu crashen. + DHL_API_KEY: "" + DHL_API_SECRET: "" + DHL_GKP_USERNAME: "" + DHL_GKP_PASSWORD: "" + DHL_ACCOUNT_NUMBER: "" + # Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die + # Einrichtung in Azure. ENTRA_REDIRECT_URI muss auf die echte, oeffentliche + # Domain zeigen (z.B. https://shop.example.com/api/auth/entra/callback). + ENTRA_TENANT_ID: "" + ENTRA_CLIENT_ID: "" + ENTRA_CLIENT_SECRET: "" + ENTRA_REDIRECT_URI: "" + # ApiCore-Ernten-Sync (Admin -> Ernten -> Sync), siehe .env.example. + APICORE_API_URL: "" + APICORE_SYNC_EMAIL: "" + APICORE_SYNC_PASSWORD: "" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e42c3fd..965e934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: nanoid: '>=3.3.18' + mariadb: '>=3.4.6' importers: @@ -27,7 +28,7 @@ importers: specifier: ^6.2.8 version: 6.2.8 mariadb: - specifier: ^3.5.3 + specifier: '>=3.4.6' version: 3.5.3 motion: specifier: ^13.0.0 @@ -1231,9 +1232,6 @@ packages: '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - '@types/nodemailer@8.0.1': resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} @@ -2071,6 +2069,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2322,10 +2321,6 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -2727,9 +2722,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -2748,10 +2740,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - mariadb@3.4.5: - resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} - engines: {node: '>= 14'} - mariadb@3.5.3: resolution: {integrity: sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==} engines: {node: '>= 20.0.0'} @@ -3491,9 +3479,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@8.10.0: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} @@ -4249,7 +4234,7 @@ snapshots: '@prisma/adapter-mariadb@7.9.1': dependencies: '@prisma/driver-adapter-utils': 7.9.1 - mariadb: 3.4.5 + mariadb: 3.5.3 '@prisma/adapter-pg@7.9.1': dependencies: @@ -4633,10 +4618,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.13.3': - dependencies: - undici-types: 7.18.2 - '@types/nodemailer@8.0.1': dependencies: '@types/node': 20.19.43 @@ -5941,10 +5922,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -6302,8 +6279,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@10.4.3: {} - lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -6318,14 +6293,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - mariadb@3.4.5: - dependencies: - '@types/geojson': 7946.0.16 - '@types/node': 24.13.3 - denque: 2.1.0 - iconv-lite: 0.6.3 - lru-cache: 10.4.3 - mariadb@3.5.3: dependencies: '@types/geojson': 7946.0.16 @@ -7137,8 +7104,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} - undici@8.10.0: {} unicode-properties@1.4.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a0cb9b9..f77f77c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,3 +9,10 @@ overrides: # GHSA-2v37-7h3g-55p8: DoS bei size=0 in aelteren nanoid-Versionen, ueber # postcss/vite transitiv eingezogen (next, @tailwindcss/postcss, vitest). nanoid: '>=3.3.18' + # GHSA-cqhc-2h57-wpxf: mariadb < 3.4.6 leakt das Klartext-Passwort an einen + # MitM trotz ssl:true. @prisma/adapter-mariadb@7.9.1 (genutzt in + # lib/db-adapter.ts fuer DATABASE_PROVIDER=mysql) bringt intern eine + # eigene, veraltete mariadb@3.4.5 mit, die vom direkten mariadb-Eintrag in + # den dependencies (bereits auf ^3.5.3) NICHT ueberschrieben wird — pnpm + # haelt dafuer sonst zwei getrennte Kopien vor. + mariadb: '>=3.4.6' diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e745af0..f3ed299 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,12 @@ model Harvest { /// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den /// Normalisierungs-Helfer beim Lesen. images Json @default("[]") + /// Steuert Sichtbarkeit auf den oeffentlichen Shop-/Ernten-Seiten. + /// Default true, damit alle VOR dieser Spalte angelegten Ernten sofort + /// sichtbar bleiben. Neu aus ApiCore synchronisierte Ernten werden explizit + /// mit false angelegt (siehe admin/actions/apicore-sync.ts) — vorbefuellt, + /// aber erst nach Admin-Review ueber "Veroeffentlichen" oeffentlich sichtbar. + isPublished Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index f1fbf64..a9a0d52 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -12,3 +12,5 @@ export * from "./actions/shipping"; export * from "./actions/reviews"; export * from "./actions/discount-codes"; export * from "./actions/content"; +export * from "./actions/apicore-sync"; +export * from "./actions/dhl"; diff --git a/src/app/admin/actions/harvests-batches.ts b/src/app/admin/actions/harvests-batches.ts index 4ab4d61..82c7d37 100644 --- a/src/app/admin/actions/harvests-batches.ts +++ b/src/app/admin/actions/harvests-batches.ts @@ -11,6 +11,11 @@ export async function createHarvestAction(formData: FormData): Promise { const year = num(formData, "year"); const type = str(formData, "type"); const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`; + // Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz + // (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer + // manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor + // Einfuehrung von isPublished). + const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on"; const harvest = await prisma.harvest.create({ data: { @@ -20,6 +25,7 @@ export async function createHarvestAction(formData: FormData): Promise { harvestDate: new Date(str(formData, "harvestDate")), location: str(formData, "location") || "Altenmünster", description: str(formData, "description"), + isPublished, }, }); @@ -31,12 +37,14 @@ export async function createHarvestAction(formData: FormData): Promise { }); revalidatePath("/admin/ernten"); + revalidatePath("/ernten"); redirect("/admin/ernten"); } export async function updateHarvestAction(formData: FormData): Promise { const admin = await requireAdmin(); const id = str(formData, "id"); + const isPublished = formData.get("isPublished") === "on"; await prisma.harvest.update({ where: { id }, @@ -46,12 +54,43 @@ export async function updateHarvestAction(formData: FormData): Promise { harvestDate: new Date(str(formData, "harvestDate")), location: str(formData, "location") || "Altenmünster", description: str(formData, "description"), + isPublished, }, }); await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id }); revalidatePath("/admin/ernten"); + revalidatePath("/ernten"); + redirect("/admin/ernten"); +} + +/** + * Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von + * updateHarvestAction — so kann der Status per Knopfdruck direkt aus der + * Ernten-Liste umgeschaltet werden, ohne durch das komplette Bearbeiten- + * Formular zu muessen (insbesondere fuer den "veroeffentlichen"-Bestaetigungs- + * schritt bei aus ApiCore synchronisierten Entwuerfen, siehe apicore-sync.ts). + */ +export async function toggleHarvestPublishedAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + const isPublished = formData.get("isPublished") === "on"; + + const harvest = await prisma.harvest.update({ + where: { id }, + data: { isPublished }, + }); + + await logAdminAction(admin, { + action: isPublished ? "harvest.publish" : "harvest.unpublish", + entityType: "Harvest", + entityId: id, + detail: `${harvest.type} ${harvest.year}`, + }); + + revalidatePath("/admin/ernten"); + revalidatePath("/ernten"); redirect("/admin/ernten"); } diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index 1df5582..f8ae4c0 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -3,14 +3,16 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; import { formatPrice, formatDate } from "@/lib/format"; -import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions"; +import { updateOrderStatusAction, updateShipmentTrackingAction, createDhlLabelAction } from "../../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; +import { isDhlConfigured } from "@/lib/dhl"; export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { const t = getDictionary(await getLocale()); const statusLabels = t.orderStatusBadge; const { id } = await props.params; + const { error, message } = await props.searchParams; const order = await prisma.order.findUnique({ where: { id }, include: { @@ -24,11 +26,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best }, }); if (!order) notFound(); + const dhlConfigured = isDhlConfigured(); return (
+ {error ? ( +

{error}

+ ) : null} + {message ? ( +

{message}

+ ) : null} +

{order.orderNumber}

@@ -101,6 +111,26 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
+ {order.fulfillmentMethod === "VERSAND" && order.payment?.status === "SUCCEEDED" ? ( +
+

{t.adminOrderDetailPage.dhlHeading}

+ {order.shipment?.trackingNumber ? ( +

+ {t.adminOrderDetailPage.dhlAlreadyCreated(order.shipment.trackingNumber)} +

+ ) : dhlConfigured ? ( +
+ + +
+ ) : ( +

{t.adminOrderDetailPage.dhlNotConfigured}

+ )} +
+ ) : null} + {order.fulfillmentMethod === "VERSAND" ? (

{t.adminOrderDetailPage.trackingHeading}

diff --git a/src/app/admin/ernten/[id]/page.tsx b/src/app/admin/ernten/[id]/page.tsx index 0923305..9806db5 100644 --- a/src/app/admin/ernten/[id]/page.tsx +++ b/src/app/admin/ernten/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; -import { updateHarvestAction } from "../../actions"; +import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -17,6 +17,20 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt

{t.adminHarvestEditPage.title}

+ + {harvest.isPublished ? null : ( +
+

{t.adminHarvestEditPage.draftNotice}

+
+ + + +
+
+ )} +
@@ -68,6 +82,11 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> + +

{t.adminHarvestEditPage.isPublishedHint}

diff --git a/src/app/admin/ernten/page.tsx b/src/app/admin/ernten/page.tsx index 53d0d9f..1a6b2dc 100644 --- a/src/app/admin/ernten/page.tsx +++ b/src/app/admin/ernten/page.tsx @@ -2,12 +2,13 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; import { formatDate } from "@/lib/format"; -import { createHarvestAction } from "../actions"; +import { createHarvestAction, toggleHarvestPublishedAction, syncHarvestsFromApiCoreAction } from "../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; -export default async function AdminHarvestsPage() { +export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) { const t = getDictionary(await getLocale()); + const { synced, updated, skipped, failed, syncError } = await props.searchParams; const harvests = await prisma.harvest.findMany({ orderBy: [{ year: "desc" }, { harvestDate: "desc" }], include: { _count: { select: { batches: true, products: true } } }, @@ -17,6 +18,18 @@ export default async function AdminHarvestsPage() {

{t.adminHarvestsPage.title}

+ + {synced !== undefined ? ( +

+ {t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))} +

+ ) : null} + {syncError !== undefined ? ( +

+ {t.adminHarvestsPage.syncError} ({syncError}) +

+ ) : null} +
@@ -27,6 +40,7 @@ export default async function AdminHarvestsPage() { + @@ -40,9 +54,27 @@ export default async function AdminHarvestsPage() { + ))} @@ -51,20 +83,36 @@ export default async function AdminHarvestsPage() { {harvests.length === 0 ?

{t.adminHarvestsPage.noHarvestsYet}

: null} -
-

{t.adminHarvestsPage.newHarvestHeading}

-
-
- - -
- - -
{t.adminHarvestsPage.colLocation} {t.adminHarvestsPage.colBatches} {t.adminHarvestsPage.colProducts}{t.adminHarvestsPage.colStatus}
{harvest._count.batches} {harvest._count.products} - + + {harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft} + + + {t.adminHarvestsPage.edit} + + + {harvest.isPublished ? null : } + +