Compare commits
6
Commits
4d3100e9ba
...
5caa85ce3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5caa85ce3e | ||
|
|
04cc3061cf | ||
|
|
9e8da18875 | ||
|
|
154b75e0d8 | ||
|
|
c59913b75b | ||
|
|
b6db9254a9 |
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
-- Draft/Publish-Workflow fuer Ernten: Default true, damit alle VOR dieser
|
||||||
|
-- Migration angelegten Ernten (bislang implizit oeffentlich) sichtbar
|
||||||
|
-- bleiben. Neu aus ApiCore synchronisierte Ernten werden von der
|
||||||
|
-- Sync-Action explizit mit isPublished = false angelegt (siehe
|
||||||
|
-- admin/actions/apicore-sync.ts) und muessen erst von einem Admin ueber
|
||||||
|
-- "Veroeffentlichen" freigegeben werden.
|
||||||
|
ALTER TABLE "harvests" ADD COLUMN "isPublished" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
-- "Meine Bestellungen" (Konto) sowie der Datenexport filtern nach customerId
|
||||||
|
-- und sortieren nach createdAt absteigend — ein zusammengesetzter Index
|
||||||
|
-- bedient beides in einem Indexzugriff statt eines vollen Tabellenscans.
|
||||||
|
CREATE INDEX "orders_customerId_createdAt_idx" ON "orders"("customerId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Statusfilter in der Admin-Bestellliste (exakte Gleichheit) und Basis fuer
|
||||||
|
-- die Status-Auswertungen im Admin-Dashboard.
|
||||||
|
CREATE INDEX "orders_status_idx" ON "orders"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Datumsbereichs-Filter im Admin-Dashboard (Tages-/Monatsumsatz, Umsatztrend)
|
||||||
|
-- sowie Standardsortierung in Bestelllisten ohne weiteren Filter.
|
||||||
|
CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach
|
||||||
|
-- discountCodeId + status).
|
||||||
|
CREATE INDEX "orders_discountCodeId_idx" ON "orders"("discountCodeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
|
||||||
|
-- Rechnung, Datenexport) ueber die order.items-Relation abgefragt.
|
||||||
|
CREATE INDEX "order_items_orderId_idx" ON "order_items"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Adressliste und Standardadresse-Update im Kundenkonto filtern nach
|
||||||
|
-- customerId.
|
||||||
|
CREATE INDEX "addresses_customerId_idx" ON "addresses"("customerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Admin-Audit-Log-Seite sortiert eine unbegrenzt wachsende Tabelle nach
|
||||||
|
-- createdAt absteigend (ORDER BY + LIMIT ohne weiteren Filter).
|
||||||
|
CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
|
||||||
@@ -124,6 +124,8 @@ model Address {
|
|||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Adressliste und Standardadresse-Update im Kundenkonto filtern nach customerId.
|
||||||
|
@@index([customerId])
|
||||||
@@map("addresses")
|
@@map("addresses")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +361,16 @@ model Order {
|
|||||||
invoice Invoice?
|
invoice Invoice?
|
||||||
returnRequest ReturnRequest?
|
returnRequest ReturnRequest?
|
||||||
|
|
||||||
|
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
|
||||||
|
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
|
||||||
|
@@index([customerId, createdAt])
|
||||||
|
/// Statusfilter im Admin-Bestellungen-Bereich (exakte Gleichheit).
|
||||||
|
@@index([status])
|
||||||
|
/// Datumsbereichs-Filter (Tages-/Monatsumsatz, Umsatztrend im Admin-Dashboard)
|
||||||
|
/// sowie Standard-Sortierung in Bestelllisten ohne weiteren Filter.
|
||||||
|
@@index([createdAt])
|
||||||
|
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
|
||||||
|
@@index([discountCodeId])
|
||||||
@@map("orders")
|
@@map("orders")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +415,9 @@ model OrderItem {
|
|||||||
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
|
||||||
|
/// Rechnung) ueber die order.items-Relation abgefragt.
|
||||||
|
@@index([orderId])
|
||||||
@@map("order_items")
|
@@map("order_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +613,9 @@ model AuditLog {
|
|||||||
|
|
||||||
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull)
|
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
/// Admin-Audit-Log-Seite sortiert unbegrenzt wachsende Tabelle nach
|
||||||
|
/// createdAt absteigend (ORDER BY + LIMIT ohne weiteren Filter).
|
||||||
|
@@index([createdAt])
|
||||||
@@map("audit_logs")
|
@@map("audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import {
|
||||||
|
fetchApiCoreHarvests,
|
||||||
|
ApiCoreConfigError,
|
||||||
|
ApiCoreRequestError,
|
||||||
|
type ApiCoreHarvestWithApiaryName,
|
||||||
|
} from "@/lib/apicore-client";
|
||||||
|
import { requireAdmin } from "./_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr
|
||||||
|
* abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich
|
||||||
|
* als Idempotenz-Schluessel: ein wiederholter Sync erkennt an diesem Slug,
|
||||||
|
* ob eine ApiCore-Ernte schon einmal importiert wurde, statt bei jedem Lauf
|
||||||
|
* Duplikate anzulegen.
|
||||||
|
*/
|
||||||
|
function slugFor(sourceId: string): string {
|
||||||
|
return `apicore-${sourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
|
||||||
|
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
|
||||||
|
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
|
||||||
|
*/
|
||||||
|
function buildDescription(source: ApiCoreHarvestWithApiaryName): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||||
|
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
||||||
|
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
|
||||||
|
if (source.notes?.trim()) parts.push(source.notes.trim());
|
||||||
|
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
|
||||||
|
return `Automatisch aus ApiCore importiert. ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zieht Ernten aus ApiCore und legt sie als unveroeffentlichte Entwuerfe an
|
||||||
|
* (isPublished: false) bzw. aktualisiert bereits vorhandene, noch
|
||||||
|
* unveroeffentlichte Entwuerfe. Bereits vom Admin veroeffentlichte,
|
||||||
|
* frueher synchronisierte Ernten werden NICHT angefasst — sonst wuerde ein
|
||||||
|
* erneuter Sync eine bewusste Freigabe- und Bearbeitungsentscheidung des
|
||||||
|
* Admins stillschweigend zuruecksetzen.
|
||||||
|
*
|
||||||
|
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
|
||||||
|
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
|
||||||
|
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische
|
||||||
|
* Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim
|
||||||
|
* Review korrigiert werden.
|
||||||
|
*/
|
||||||
|
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
||||||
|
try {
|
||||||
|
sourceHarvests = await fetchApiCoreHarvests();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unbekannter Fehler";
|
||||||
|
redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const source of sourceHarvests) {
|
||||||
|
const slug = slugFor(source.id);
|
||||||
|
const date = new Date(source.date);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
failed++;
|
||||||
|
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||||
|
|
||||||
|
if (existing?.isPublished) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
year: date.getFullYear(),
|
||||||
|
type,
|
||||||
|
harvestDate: date,
|
||||||
|
location: source.apiaryName ?? "Altenmünster",
|
||||||
|
description: buildDescription(source),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.harvest.update({ where: { slug }, data });
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
failed++;
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
|
||||||
|
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
|
||||||
|
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
|
||||||
|
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "harvest.sync_apicore",
|
||||||
|
entityType: "Harvest",
|
||||||
|
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/ernten");
|
||||||
|
redirect(`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
|
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
||||||
|
import { requireAdmin, fail, str } from "./_shared";
|
||||||
|
|
||||||
|
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
|
||||||
|
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
|
||||||
|
// daher eindeutig von jeder realen Sendungsnummer unterscheidbar.
|
||||||
|
const DHL_CLAIM_SENTINEL = "__dhl_label_pending__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine
|
||||||
|
* bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im
|
||||||
|
* Shipment-Datensatz (carrier="DHL"). Idempotent: ist bereits eine
|
||||||
|
* Sendungsnummer erfasst, wird KEINE zweite Marke erzeugt (das wuerde bei
|
||||||
|
* DHL echte Kosten verursachen) — die Aktion bricht in dem Fall mit einer
|
||||||
|
* klaren Meldung ab, statt stillschweigend zu ueberschreiben.
|
||||||
|
*/
|
||||||
|
export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const orderId = str(formData, "orderId");
|
||||||
|
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
items: { select: { weightGrams: true, quantity: true } },
|
||||||
|
payment: { select: { status: true } },
|
||||||
|
shipment: { select: { trackingNumber: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) {
|
||||||
|
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.fulfillmentMethod !== "VERSAND") {
|
||||||
|
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||||
|
}
|
||||||
|
if (order.payment?.status !== "SUCCEEDED") {
|
||||||
|
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||||
|
}
|
||||||
|
if (order.shipment?.trackingNumber) {
|
||||||
|
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
|
||||||
|
// findUnique-Check allein ist nicht ausreichend — zwei schnell
|
||||||
|
// aufeinanderfolgende Klicks koennten sonst beide passieren und zwei
|
||||||
|
// echte, kostenpflichtige DHL-Sendungen anlegen (gleiche Race-Condition
|
||||||
|
// wie beim Retouren-Erstattungs-Fix). Ein Shipment-Datensatz kann bereits
|
||||||
|
// OHNE DHL existieren (z.B. automatisch beim Statuswechsel auf
|
||||||
|
// "VERSENDET", siehe admin/actions/orders.ts, oder manuell erfasst) —
|
||||||
|
// ein reines "create()" mit Unique-Constraint waere daher in diesen
|
||||||
|
// Faellen faelschlich blockiert. Serializable-Transaktion + Sentinel-Wert
|
||||||
|
// statt der echten Sendungsnummer loest beide Faelle korrekt atomar.
|
||||||
|
let claimed: boolean;
|
||||||
|
try {
|
||||||
|
claimed = await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const existing = await tx.shipment.findUnique({ where: { orderId }, select: { trackingNumber: true } });
|
||||||
|
if (existing?.trackingNumber) return false;
|
||||||
|
await tx.shipment.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
create: { orderId, carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL },
|
||||||
|
update: { carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ isolationLevel: "Serializable" }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
claimed = false;
|
||||||
|
}
|
||||||
|
if (!claimed) {
|
||||||
|
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 company = await getCompanyInfo();
|
||||||
|
|
||||||
|
let result: DhlCreateShipmentResult;
|
||||||
|
try {
|
||||||
|
result = await createDhlShipmentLabel({
|
||||||
|
reference: order.orderNumber,
|
||||||
|
weightGrams: totalWeightGrams,
|
||||||
|
shipper: {
|
||||||
|
companyName: company.name,
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
street: company.address.street,
|
||||||
|
zip: company.address.zip,
|
||||||
|
city: company.address.city,
|
||||||
|
// Firmensitz ist Deutschland (siehe lib/site-config.ts) — bewusst
|
||||||
|
// fest verdrahtet statt aus dem lokalisierten Anzeigetext
|
||||||
|
// ("Deutschland") geparst.
|
||||||
|
country: "DE",
|
||||||
|
phone: company.phone,
|
||||||
|
email: company.email,
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
firstName: order.firstName,
|
||||||
|
lastName: order.lastName,
|
||||||
|
street: order.street,
|
||||||
|
zip: order.zip,
|
||||||
|
city: order.city,
|
||||||
|
country: order.country,
|
||||||
|
phone: order.phone,
|
||||||
|
email: order.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Claim zuruecksetzen, damit ein erneuter Versuch moeglich ist — nur
|
||||||
|
// wenn der Sentinel-Wert seitdem nicht durch etwas anderes (z.B. eine
|
||||||
|
// manuelle Tracking-Eingabe waehrend der laufenden Anfrage) ersetzt
|
||||||
|
// wurde, um dessen Aenderung nicht versehentlich zu ueberschreiben.
|
||||||
|
await prisma.shipment
|
||||||
|
.updateMany({ where: { orderId, trackingNumber: DHL_CLAIM_SENTINEL }, data: { trackingNumber: null } })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (error instanceof DhlNotConfiguredError) {
|
||||||
|
fail(detailPath, error.message);
|
||||||
|
}
|
||||||
|
if (error instanceof DhlApiError) {
|
||||||
|
fail(detailPath, error.message);
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.shipment.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||||
|
update: { carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "order.dhl_label_created",
|
||||||
|
entityType: "Order",
|
||||||
|
entityId: orderId,
|
||||||
|
detail: `${order.orderNumber}: DHL-Sendungsnummer ${result.trackingNumber}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/bestellungen");
|
||||||
|
revalidatePath(detailPath);
|
||||||
|
}
|
||||||
@@ -68,8 +68,6 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Nur eine angenommene Retoure kann erstattet werden — verhindert
|
|
||||||
// Doppel-Erstattung, falls dieselbe Aktion mehrfach ausgeloest wird.
|
|
||||||
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
|
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
|
||||||
|
|
||||||
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
||||||
@@ -77,11 +75,29 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
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
|
||||||
|
// den noch erwarteten "ANGENOMMEN"-Stand) statt erst danach: der
|
||||||
|
// findUnique-Check allein ist nicht atomar — bei zwei schnell
|
||||||
|
// aufeinanderfolgenden Klicks (Doppelklick, zwei offene Tabs) koennten
|
||||||
|
// sonst beide Aufrufe die Pruefung bestehen und zwei echte
|
||||||
|
// Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl,
|
||||||
|
// wird der Status wieder zurueckgesetzt, damit die Erstattung erneut
|
||||||
|
// versucht werden kann.
|
||||||
|
const claimed = await prisma.returnRequest.updateMany({
|
||||||
|
where: { id, status: "ANGENOMMEN" },
|
||||||
|
data: { status: "ERSTATTET" },
|
||||||
|
});
|
||||||
|
if (claimed.count === 0) return;
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient();
|
||||||
await stripe.refunds.create({ payment_intent: paymentIntentId });
|
try {
|
||||||
|
await stripe.refunds.create({ payment_intent: paymentIntentId });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await prisma.returnRequest.update({ where: { id }, data: { status: "ANGENOMMEN" } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } });
|
|
||||||
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
|
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
|
||||||
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
|
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
|
||||||
// zur Stornierung im Checkout-Webhook.
|
// zur Stornierung im Checkout-Webhook.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { createSession, hashPassword, getEntraOAuthCookie, clearEntraOAuthCookie } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
isEntraConfigured,
|
||||||
|
exchangeEntraAuthorizationCode,
|
||||||
|
verifyEntraIdToken,
|
||||||
|
getVerifiedEmail,
|
||||||
|
EntraAuthError,
|
||||||
|
EntraNotConfiguredError,
|
||||||
|
type EntraIdTokenClaims,
|
||||||
|
} from "@/lib/entra";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const CALLBACK_ATTEMPTS_PER_WINDOW = 20;
|
||||||
|
const CALLBACK_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function firstNameFromClaims(claims: EntraIdTokenClaims): string {
|
||||||
|
const value = claims.given_name?.trim() || claims.name?.trim().split(/\s+/)[0] || "Kundin/Kunde";
|
||||||
|
return value.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastNameFromClaims(claims: EntraIdTokenClaims): string {
|
||||||
|
const fromName = claims.name?.trim().split(/\s+/).slice(1).join(" ");
|
||||||
|
const value = claims.family_name?.trim() || (fromName && fromName.length > 0 ? fromName : "—");
|
||||||
|
return value.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback fuer "Mit Microsoft anmelden" — tauscht den Autorisierungscode
|
||||||
|
* gegen Tokens, verifiziert das ID-Token serverseitig (Signatur/issuer/
|
||||||
|
* audience, siehe lib/entra.ts) und meldet die Kundin/den Kunden an. Legt
|
||||||
|
* bei erstmaliger Anmeldung automatisch ein Customer-Konto (Rolle KUNDE) an.
|
||||||
|
*
|
||||||
|
* Sicherheitskritisch: Es wird AUSSCHLIESSLICH die verifizierte E-Mail aus
|
||||||
|
* dem signierten ID-Token verwendet, nie ein Query-Parameter oder anderer
|
||||||
|
* Client-Wert — sonst koennte sich jemand als beliebige E-Mail-Adresse
|
||||||
|
* ausgeben und ein fremdes Konto uebernehmen/anlegen.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
function redirectWithError(message: string): NextResponse {
|
||||||
|
const url = new URL("/konto", baseUrl);
|
||||||
|
url.searchParams.set("error", message);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEntraConfigured()) {
|
||||||
|
return redirectWithError("SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (!rateLimit(`entra-callback:${ip}`, CALLBACK_ATTEMPTS_PER_WINDOW, CALLBACK_WINDOW_MS)) {
|
||||||
|
return redirectWithError("Zu viele Anmeldeversuche. Bitte versuche es später erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const providerError = requestUrl.searchParams.get("error");
|
||||||
|
const code = requestUrl.searchParams.get("code");
|
||||||
|
const returnedState = requestUrl.searchParams.get("state");
|
||||||
|
|
||||||
|
// Das state/code_verifier-Cookie ist Einmalgebrauch — sofort loeschen,
|
||||||
|
// unabhaengig davon, ob der Rest der Pruefung gleich erfolgreich ist.
|
||||||
|
const stored = await getEntraOAuthCookie();
|
||||||
|
await clearEntraOAuthCookie();
|
||||||
|
|
||||||
|
if (providerError) {
|
||||||
|
return redirectWithError("Die Microsoft-Anmeldung wurde abgebrochen.");
|
||||||
|
}
|
||||||
|
if (!code || !returnedState || !stored || stored.state !== returnedState) {
|
||||||
|
return redirectWithError("Die Anmeldung ist abgelaufen oder ungültig. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims: EntraIdTokenClaims;
|
||||||
|
try {
|
||||||
|
const tokens = await exchangeEntraAuthorizationCode({ code, codeVerifier: stored.codeVerifier });
|
||||||
|
claims = await verifyEntraIdToken(tokens.id_token);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof EntraAuthError || error instanceof EntraNotConfiguredError
|
||||||
|
? error.message
|
||||||
|
: "Microsoft-Anmeldung fehlgeschlagen.";
|
||||||
|
return redirectWithError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = getVerifiedEmail(claims);
|
||||||
|
if (!email) {
|
||||||
|
return redirectWithError(
|
||||||
|
"Dein Microsoft-Konto hat keine verifizierte E-Mail-Adresse hinterlegt. Bitte melde dich mit E-Mail und Passwort an oder wende dich an uns."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let customer = await prisma.customer.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
if (customer?.blockedAt) {
|
||||||
|
return redirectWithError("Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
// SSO-Konten haben kein von uns gesetztes Passwort. Statt passwordHash
|
||||||
|
// per Migration nullable zu machen (und damit ueberall im Code, wo
|
||||||
|
// Customer.passwordHash als garantiert vorhanden gilt — Login,
|
||||||
|
// Passwort-Reset, Datenexport, 2FA — Nullable-Handling nachzuruesten),
|
||||||
|
// erhaelt das Konto denselben "unbenutzbaren Zufalls-Hash" wie von
|
||||||
|
// Admins ohne Passwort angelegte Konten (siehe
|
||||||
|
// admin/actions/customers.ts createCustomerAction): ein bcrypt-Hash
|
||||||
|
// ueber 32 zufaellige Bytes, die nirgends gespeichert werden. Ein Login
|
||||||
|
// per Passwort ist damit praktisch unmoeglich; Zugang bleibt
|
||||||
|
// ausschliesslich ueber "Mit Microsoft anmelden" (oder "Passwort
|
||||||
|
// vergessen", falls spaeter ein echtes Passwort gewuenscht ist).
|
||||||
|
const unusablePassword = randomBytes(32).toString("hex");
|
||||||
|
customer = await prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
firstName: firstNameFromClaims(claims),
|
||||||
|
lastName: lastNameFromClaims(claims),
|
||||||
|
passwordHash: await hashPassword(unusablePassword),
|
||||||
|
// Microsoft hat die Adresse bereits verifiziert (signierter
|
||||||
|
// "email"-Claim) — eine zusaetzliche eigene Bestaetigungsmail waere
|
||||||
|
// redundant.
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (!customer.emailVerifiedAt) {
|
||||||
|
// Bestehendes Konto (z.B. aus einer fruehen Registrierung oder
|
||||||
|
// Gastbestellung mit spaeter angelegtem Konto) meldet sich erstmals per
|
||||||
|
// SSO an — Microsoft hat die Adresse soeben verifiziert, das muss nicht
|
||||||
|
// zusaetzlich per E-Mail-Link bestaetigt werden.
|
||||||
|
customer = await prisma.customer.update({
|
||||||
|
where: { id: customer.id },
|
||||||
|
data: { emailVerifiedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anmeldung per SSO wird bewusst als vollwertig authentifiziert behandelt
|
||||||
|
// und ueberspringt die App-eigene 2FA-Abfrage (falls fuer das Konto
|
||||||
|
// aktiviert): Microsoft ist hier die pruefende Authentifizierungsstelle,
|
||||||
|
// deren eigene Absicherung (bis hin zu Microsofts eigener MFA) die Rolle
|
||||||
|
// unseres TOTP-Schritts uebernimmt.
|
||||||
|
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||||
|
return NextResponse.redirect(new URL("/konto", baseUrl));
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { setEntraOAuthCookie } from "@/lib/auth";
|
||||||
|
import { signEntraOAuthState } from "@/lib/session";
|
||||||
|
import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto):
|
||||||
|
* erzeugt PKCE-Paar + CSRF-state, hinterlegt beides in einem kurzlebigen,
|
||||||
|
* signierten Cookie (siehe lib/session.ts ENTRA_OAUTH_COOKIE) und leitet zu
|
||||||
|
* Microsofts /authorize-Endpunkt weiter.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
if (!isEntraConfigured()) {
|
||||||
|
const url = new URL("/konto", getBaseUrl());
|
||||||
|
url.searchParams.set("error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an.");
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { codeVerifier, codeChallenge } = generatePkcePair();
|
||||||
|
const state = generateOAuthState();
|
||||||
|
const cookieToken = await signEntraOAuthState(state, codeVerifier);
|
||||||
|
await setEntraOAuthCookie(cookieToken);
|
||||||
|
|
||||||
|
return NextResponse.redirect(buildEntraAuthorizeUrl({ state, codeChallenge }));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { getStripeClient } from "@/lib/stripe";
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
import { checkoutSchema } from "@/lib/validation";
|
import { checkoutSchema } from "@/lib/validation";
|
||||||
import { getProductsByIds } from "@/lib/data";
|
import { getProductsByIds } from "@/lib/data";
|
||||||
@@ -14,6 +15,18 @@ import { getBaseUrl } from "@/lib/url";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
|
||||||
class OutOfStockError extends Error {}
|
class OutOfStockError extends Error {}
|
||||||
|
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
||||||
|
// — zwei zeitgleiche Checkouts mit demselben maxUses:1-Code koennten ihn
|
||||||
|
// beide bestehen, bevor eine der beiden Bestellungen tatsaechlich angelegt
|
||||||
|
// ist (TOCTOU). Dieser Fehlertyp markiert den verbindlichen Re-Check direkt
|
||||||
|
// vor dem Anlegen der Bestellung (s.u., innerhalb der Serializable-Transaktion).
|
||||||
|
class DiscountCodeExhaustedError extends Error {}
|
||||||
|
// Postgres/MySQL brechen bei "Serializable"-Isolation eine von zwei
|
||||||
|
// widerspruechlichen, gleichzeitigen Transaktionen mit einem
|
||||||
|
// Serialisierungsfehler ab, statt still beide durchzulassen — das ist der
|
||||||
|
// eigentliche Schutz gegen die Race-Condition, der explizite Re-Check oben
|
||||||
|
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
|
||||||
|
const SERIALIZATION_CONFLICT_CODE = "P2034";
|
||||||
|
|
||||||
function generateOrderNumber(): string {
|
function generateOrderNumber(): string {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
@@ -173,43 +186,71 @@ export async function POST(request: Request) {
|
|||||||
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
||||||
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
||||||
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
||||||
order = await prisma.$transaction(async (tx) => {
|
order = await prisma.$transaction(
|
||||||
for (const item of orderItemsData) {
|
async (tx) => {
|
||||||
const reserved = await tx.inventory.updateMany({
|
for (const item of orderItemsData) {
|
||||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
const reserved = await tx.inventory.updateMany({
|
||||||
data: { quantity: { decrement: item.quantity } },
|
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||||
});
|
data: { quantity: { decrement: item.quantity } },
|
||||||
if (reserved.count === 0) {
|
});
|
||||||
throw new OutOfStockError(item.productName);
|
if (reserved.count === 0) {
|
||||||
|
throw new OutOfStockError(item.productName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return tx.order.create({
|
// Verbindlicher Re-Check direkt vor dem Anlegen der Bestellung: der
|
||||||
data: {
|
// fruehe pendingUses-Check oben ist nur ein schneller Vorabfilter
|
||||||
orderNumber: generateOrderNumber(),
|
// (bessere Fehlermeldung fuer den Normalfall), dieser hier — innerhalb
|
||||||
customerId: customer?.id,
|
// derselben Serializable-Transaktion wie die Bestellanlage, mit einem
|
||||||
locale,
|
// frischen Datenbank-Stand statt der aeusseren (potenziell leicht
|
||||||
fulfillmentMethod: input.fulfillmentMethod,
|
// veralteten) Momentaufnahme — ist der tatsaechlich verbindliche
|
||||||
email: input.email,
|
// Schutz gegen zwei gleichzeitige Checkouts mit demselben knapp
|
||||||
firstName: input.firstName,
|
// begrenzten Code.
|
||||||
lastName: input.lastName,
|
if (discountCode) {
|
||||||
phone: input.phone,
|
const freshCode = await tx.discountCode.findUnique({ where: { id: discountCode.id } });
|
||||||
street: input.street ?? "",
|
if (freshCode && freshCode.maxUses !== null) {
|
||||||
zip: input.zip ?? "",
|
const pendingUses = await tx.order.count({
|
||||||
city: input.city ?? "",
|
where: { discountCodeId: freshCode.id, status: "NEU" },
|
||||||
country: input.country,
|
});
|
||||||
subtotalCents,
|
if (freshCode.usedCount + pendingUses >= freshCode.maxUses) {
|
||||||
shippingCents,
|
throw new DiscountCodeExhaustedError();
|
||||||
discountCents,
|
}
|
||||||
discountCodeId: discountCode?.id,
|
}
|
||||||
totalCents,
|
}
|
||||||
giftMessage: input.giftMessage || null,
|
|
||||||
items: {
|
return tx.order.create({
|
||||||
create: orderItemsData,
|
data: {
|
||||||
|
orderNumber: generateOrderNumber(),
|
||||||
|
customerId: customer?.id,
|
||||||
|
locale,
|
||||||
|
fulfillmentMethod: input.fulfillmentMethod,
|
||||||
|
email: input.email,
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
phone: input.phone,
|
||||||
|
street: input.street ?? "",
|
||||||
|
zip: input.zip ?? "",
|
||||||
|
city: input.city ?? "",
|
||||||
|
country: input.country,
|
||||||
|
subtotalCents,
|
||||||
|
shippingCents,
|
||||||
|
discountCents,
|
||||||
|
discountCodeId: discountCode?.id,
|
||||||
|
totalCents,
|
||||||
|
giftMessage: input.giftMessage || null,
|
||||||
|
items: {
|
||||||
|
create: orderItemsData,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
});
|
// Serializable statt der DB-Voreinstellung ("Read Committed"): erst
|
||||||
|
// dadurch erkennt die Datenbank selbst den Konflikt zwischen zwei
|
||||||
|
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
|
||||||
|
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
|
||||||
|
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
|
||||||
|
{ isolationLevel: "Serializable" }
|
||||||
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof OutOfStockError) {
|
if (error instanceof OutOfStockError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -219,6 +260,18 @@ export async function POST(request: Request) {
|
|||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (error instanceof DiscountCodeExhaustedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === SERIALIZATION_CONFLICT_CODE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Kurzzeitig zu viele gleichzeitige Bestellungen. Bitte versuche es in ein paar Sekunden erneut." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
export const size = { width: 180, height: 180 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple-Touch-Icon fuer "Zum Home-Bildschirm hinzufuegen" auf iOS/iPadOS —
|
||||||
|
* Apple akzeptiert dafuer ausschliesslich PNG/JPG, kein SVG (anders als
|
||||||
|
* app/icon.svg, das moderne Chrome/Android direkt lesen, siehe
|
||||||
|
* app/manifest.ts). Ohne diese Datei nimmt iOS ersatzweise einen
|
||||||
|
* Screenshot der Seite als Icon — unbrandet und meist unbrauchbar. Gleiche
|
||||||
|
* Grafik wie app/icon.svg, per ImageResponse als Raster gerendert.
|
||||||
|
*/
|
||||||
|
export default function AppleIcon() {
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<svg width={180} height={180} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width={100} height={100} rx={20} fill="#24352B" />
|
||||||
|
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" strokeWidth={4} />
|
||||||
|
<g fill="#F4EFE5">
|
||||||
|
<g transform="translate(26,42) scale(0.62)">
|
||||||
|
<polygon points="10,2 5,13 15,13" />
|
||||||
|
<polygon points="10,10 2,22 18,22" />
|
||||||
|
<rect x={8} y={22} width={4} height={4} />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(40,30) scale(0.82)">
|
||||||
|
<polygon points="10,2 5,13 15,13" />
|
||||||
|
<polygon points="10,10 2,22 18,22" />
|
||||||
|
<rect x={8} y={22} width={4} height={4} />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(56,42) scale(0.62)">
|
||||||
|
<polygon points="10,2 5,13 15,13" />
|
||||||
|
<polygon points="10,10 2,22 18,22" />
|
||||||
|
<rect x={8} y={22} width={4} height={4} />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-17
@@ -1,34 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Container, Button, LinkButton } from "@/components/ui";
|
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
export default function Error({
|
/**
|
||||||
error,
|
* Next.js' Datei-Konvention fuer Laufzeitfehler beim Rendern einer Seite —
|
||||||
reset,
|
* ohne diese Datei zeigt Next.js in Produktion eine generische, unbrandete
|
||||||
}: {
|
* Fehlerseite. MUSS eine Client Component sein (Next.js-Vorgabe fuer
|
||||||
error: Error & { digest?: string };
|
* error.tsx); rendert innerhalb desselben Layout-Baums wie die restliche
|
||||||
reset: () => void;
|
* Seite, useI18n() ist daher verfuegbar (I18nProvider sitzt im Root-Layout
|
||||||
}) {
|
* oberhalb, siehe app/layout.tsx).
|
||||||
|
*/
|
||||||
|
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Serverseitig geloggt Next.js Rendering-Fehler bereits automatisch;
|
||||||
|
// dieses Log dient nur der Sichtbarkeit im Browser fuer den Fall eines
|
||||||
|
// client-seitig ausgeloesten Fehlers.
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-32">
|
<div className="py-24">
|
||||||
<Container className="max-w-xl text-center">
|
<Container className="max-w-xl text-center">
|
||||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
<SectionHeading
|
||||||
<h1 className="text-3xl md:text-4xl text-forest">{t.errorPage.title}</h1>
|
eyebrow={t.siteErrorPage.eyebrow}
|
||||||
<p className="mt-4 text-ink/70 leading-relaxed">{t.errorPage.description}</p>
|
title={t.siteErrorPage.title}
|
||||||
<div className="mt-8 flex items-center justify-center gap-4">
|
align="center"
|
||||||
<Button type="button" onClick={() => reset()}>
|
className="mx-auto"
|
||||||
{t.errorPage.retry}
|
/>
|
||||||
</Button>
|
<p className="mt-6 text-ink/70">{t.siteErrorPage.text}</p>
|
||||||
|
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<Button onClick={reset}>{t.siteErrorPage.retry}</Button>
|
||||||
<LinkButton href="/" variant="secondary">
|
<LinkButton href="/" variant="secondary">
|
||||||
{t.errorPage.backHome}
|
{t.siteErrorPage.backToHome}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Header, Footer, CartDrawer } from "@/components/site-chrome";
|
import { Header, CartDrawer } from "@/components/site-chrome";
|
||||||
|
import { Footer } from "@/components/site-footer";
|
||||||
import { CookieConsentBanner } from "@/components/cookie-consent";
|
import { CookieConsentBanner } from "@/components/cookie-consent";
|
||||||
import { siteConfig } from "@/lib/site-config";
|
import { siteConfig } from "@/lib/site-config";
|
||||||
import { getCompanyInfo } from "@/lib/company-info";
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
|
|||||||
+22
-13
@@ -1,22 +1,31 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Container, LinkButton } from "@/components/ui";
|
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Seite nicht gefunden", robots: { index: false } };
|
export const metadata: Metadata = { title: "Seite nicht gefunden" };
|
||||||
|
|
||||||
export default function NotFound() {
|
/**
|
||||||
|
* Next.js' Datei-Konvention fuer nicht gefundene Routen (notFound()-Aufrufe
|
||||||
|
* und unbekannte Pfade) — ohne diese Datei zeigt Next.js eine generische,
|
||||||
|
* unbrandete 404-Seite statt einer zur Seite passenden Nutzerfuehrung.
|
||||||
|
*/
|
||||||
|
export default async function NotFound() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
return (
|
return (
|
||||||
<div className="py-32">
|
<div className="py-24">
|
||||||
<Container className="max-w-xl text-center">
|
<Container className="max-w-xl text-center">
|
||||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">404</p>
|
<SectionHeading
|
||||||
<h1 className="text-3xl md:text-4xl text-forest">Diese Seite gibt es hier nicht.</h1>
|
eyebrow={t.notFoundPage.eyebrow}
|
||||||
<p className="mt-4 text-ink/70 leading-relaxed">
|
title={t.notFoundPage.title}
|
||||||
Der Link ist möglicherweise veraltet, oder die Seite wurde verschoben. Im Shop findest du
|
align="center"
|
||||||
garantiert, wonach du suchst.
|
className="mx-auto"
|
||||||
</p>
|
/>
|
||||||
<div className="mt-8 flex items-center justify-center gap-4">
|
<p className="mt-6 text-ink/70">{t.notFoundPage.text}</p>
|
||||||
<LinkButton href="/shop">Zum Shop</LinkButton>
|
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<LinkButton href="/shop">{t.notFoundPage.backToShop}</LinkButton>
|
||||||
<LinkButton href="/" variant="secondary">
|
<LinkButton href="/" variant="secondary">
|
||||||
Zur Startseite
|
{t.notFoundPage.backToHome}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState, useEffect, useRef, type RefObject, type ReactNode } from "react";
|
import { useState, useEffect, useRef, type RefObject } from "react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { mainNav, footerNav } from "@/lib/site-config";
|
import { mainNav } from "@/lib/site-config";
|
||||||
import { Container, LinkButton } from "@/components/ui";
|
import { Container, LinkButton } from "@/components/ui";
|
||||||
import { Logomark } from "@/components/logomark";
|
import { Logomark } from "@/components/logomark";
|
||||||
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||||
@@ -512,180 +512,5 @@ export function CartDrawer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erstes vollstaendiges Geschaeftsjahr — Startpunkt der im Footer
|
// Footer wurde nach @/components/site-footer.tsx ausgelagert (Server
|
||||||
// angezeigten Copyright-Spanne "2023–<aktuelles Jahr>". Das Endjahr wird
|
// Component statt Teil dieser "use client"-Datei) — siehe Kommentar dort.
|
||||||
// bewusst NICHT gespeichert, sondern bei jedem Render live aus
|
|
||||||
// new Date().getFullYear() berechnet, damit es nie per Hand nachgepflegt
|
|
||||||
// werden muss.
|
|
||||||
const FOUNDING_YEAR = 2023;
|
|
||||||
|
|
||||||
const SOCIAL_ICON_PROPS = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.5 } as const;
|
|
||||||
|
|
||||||
function TikTokIcon() {
|
|
||||||
return (
|
|
||||||
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
|
|
||||||
<path d="M14 4v10.5a3.5 3.5 0 1 1-3.5-3.5" />
|
|
||||||
<path d="M14 4c.3 1.8 1.6 3.6 4 4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function YouTubeIcon() {
|
|
||||||
return (
|
|
||||||
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
|
|
||||||
<rect x="2" y="5" width="20" height="14" rx="4" />
|
|
||||||
<path d="M10 9l6 3-6 3V9z" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InstagramIcon() {
|
|
||||||
return (
|
|
||||||
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="5" />
|
|
||||||
<circle cx="12" cy="12" r="4" />
|
|
||||||
<circle cx="17.5" cy="6.5" r="0.75" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SocialLinks {
|
|
||||||
tiktok: string;
|
|
||||||
youtube: string;
|
|
||||||
instagram: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ein Icon+Name-Link wird nur gerendert, wenn im Admin-Bereich (Inhalte ->
|
|
||||||
* Social Media) tatsaechlich eine URL hinterlegt ist — nie ein toter/
|
|
||||||
* Platzhalter-Link (siehe getSocialLinks()/content-registry.ts).
|
|
||||||
*/
|
|
||||||
function SocialLink({
|
|
||||||
href,
|
|
||||||
name,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
href: string;
|
|
||||||
/** Plattformname (Eigenname, z.B. "TikTok") — unlokalisiert, dient als sichtbarer Linktext. */
|
|
||||||
name: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={t.footer.socialAriaLabel(name)}
|
|
||||||
className="flex items-center gap-1.5 text-cream/70 hover:text-cream transition-colors"
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span className="text-xs">{name}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Footer({
|
|
||||||
companyInfo,
|
|
||||||
socialLinks,
|
|
||||||
}: {
|
|
||||||
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
|
|
||||||
socialLinks: SocialLinks;
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
|
|
||||||
|
|
||||||
const wabenhainLabels: Record<(typeof footerNav.wabenhain)[number]["key"], string> = {
|
|
||||||
aboutUs: t.footer.aboutUs,
|
|
||||||
origin: t.nav.origin,
|
|
||||||
harvests: t.nav.harvests,
|
|
||||||
contact: t.nav.contact,
|
|
||||||
};
|
|
||||||
const einkaufenLabels: Record<(typeof footerNav.einkaufen)[number]["key"], string> = {
|
|
||||||
shop: t.nav.shop,
|
|
||||||
businessCustomers: t.footer.businessCustomers,
|
|
||||||
trackOrder: t.footer.trackOrder,
|
|
||||||
};
|
|
||||||
const rechtlichesLabels: Record<(typeof footerNav.rechtliches)[number]["key"], string> = {
|
|
||||||
impressum: t.legalTitles.impressum,
|
|
||||||
datenschutz: t.legalTitles.datenschutz,
|
|
||||||
agb: t.legalTitles.agb,
|
|
||||||
widerruf: t.legalTitles.widerruf,
|
|
||||||
versandZahlung: t.legalTitles.versandZahlung,
|
|
||||||
cookieRichtlinie: t.legalTitles.cookieRichtlinie,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="bg-forest text-cream mt-24">
|
|
||||||
<Container className="py-16">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Logomark className="h-9 w-9" variant="inverted" />
|
|
||||||
<span className="text-2xl tracking-[0.2em] font-serif">
|
|
||||||
{companyInfo.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-cream/70 text-sm max-w-xs">{t.footer.tagline}</p>
|
|
||||||
<p className="mt-6 text-cream/60 text-sm">
|
|
||||||
{companyInfo.address.street}
|
|
||||||
<br />
|
|
||||||
{companyInfo.address.zip} {companyInfo.address.city}
|
|
||||||
<br />
|
|
||||||
{companyInfo.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
|
|
||||||
<FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} />
|
|
||||||
<FooterColumn title={t.footer.columnLegal} links={footerNav.rechtliches} labels={rechtlichesLabels} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-16 pt-8 border-t border-cream/15 flex flex-col md:flex-row md:items-center justify-between gap-4 text-xs text-cream/70">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
|
||||||
<span>
|
|
||||||
© {FOUNDING_YEAR}–{new Date().getFullYear()} {companyInfo.name}
|
|
||||||
</span>
|
|
||||||
{hasAnySocialLink ? (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{socialLinks.tiktok ? (
|
|
||||||
<SocialLink href={socialLinks.tiktok} name="TikTok" icon={<TikTokIcon />} />
|
|
||||||
) : null}
|
|
||||||
{socialLinks.youtube ? (
|
|
||||||
<SocialLink href={socialLinks.youtube} name="YouTube" icon={<YouTubeIcon />} />
|
|
||||||
) : null}
|
|
||||||
{socialLinks.instagram ? (
|
|
||||||
<SocialLink href={socialLinks.instagram} name="Instagram" icon={<InstagramIcon />} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span>{t.footer.bottomLine}</span>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FooterColumn({
|
|
||||||
title,
|
|
||||||
links,
|
|
||||||
labels,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
links: readonly { href: string; key: string }[];
|
|
||||||
labels: Record<string, string>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="tracking-wide-caps text-xs text-honey-light mb-4">{title}</p>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{links.map((link) => (
|
|
||||||
<li key={link.key}>
|
|
||||||
<Link href={link.href} className="text-sm text-cream/75 hover:text-cream">
|
|
||||||
{labels[link.key]}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Container } from "@/components/ui";
|
||||||
|
import { Logomark } from "@/components/logomark";
|
||||||
|
import { footerNav } from "@/lib/site-config";
|
||||||
|
import type { SocialLinks } from "@/lib/social-links";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
|
// Erstes vollstaendiges Geschaeftsjahr — Startpunkt der im Footer
|
||||||
|
// angezeigten Copyright-Spanne "2023–<aktuelles Jahr>". Das Endjahr wird
|
||||||
|
// bewusst NICHT gespeichert, sondern bei jedem Render live aus
|
||||||
|
// new Date().getFullYear() berechnet, damit es nie per Hand nachgepflegt
|
||||||
|
// werden muss.
|
||||||
|
const FOUNDING_YEAR = 2023;
|
||||||
|
|
||||||
|
const SOCIAL_ICON_PROPS = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.5 } as const;
|
||||||
|
|
||||||
|
function TikTokIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
|
||||||
|
<path d="M14 4v10.5a3.5 3.5 0 1 1-3.5-3.5" />
|
||||||
|
<path d="M14 4c.3 1.8 1.6 3.6 4 4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YouTubeIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="4" />
|
||||||
|
<path d="M10 9l6 3-6 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstagramIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="5" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="17.5" cy="6.5" r="0.75" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Icon+Name-Link wird nur gerendert, wenn im Admin-Bereich (Inhalte ->
|
||||||
|
* Social Media) tatsaechlich eine URL hinterlegt ist — nie ein toter/
|
||||||
|
* Platzhalter-Link (siehe getSocialLinks()/content-registry.ts).
|
||||||
|
*/
|
||||||
|
function SocialLink({
|
||||||
|
href,
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
/** Plattformname (Eigenname, z.B. "TikTok") — unlokalisiert, dient als sichtbarer Linktext. */
|
||||||
|
name: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
t: Dictionary;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t.footer.socialAriaLabel(name)}
|
||||||
|
className="flex items-center gap-1.5 text-cream/70 hover:text-cream transition-colors"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs">{name}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Component (bewusst NICHT Teil von site-chrome.tsx/"use client"):
|
||||||
|
* der Footer ist bis auf die Uebersetzungen rein statisches Markup, das auf
|
||||||
|
* jeder Seite gerendert wird. Statt useI18n() (Client-Hook) wird die
|
||||||
|
* Uebersetzung wie bei anderen Server Components direkt per
|
||||||
|
* getDictionary(await getLocale()) aufgeloest — dadurch verlaesst dieser
|
||||||
|
* Block das Client-JS-Bundle vollstaendig (siehe Kommentar in
|
||||||
|
* i18n/i18n-provider.tsx zur RSC-Serialisierungsgrenze).
|
||||||
|
*/
|
||||||
|
export async function Footer({
|
||||||
|
companyInfo,
|
||||||
|
socialLinks,
|
||||||
|
}: {
|
||||||
|
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
|
||||||
|
socialLinks: SocialLinks;
|
||||||
|
}) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
|
||||||
|
|
||||||
|
const wabenhainLabels: Record<(typeof footerNav.wabenhain)[number]["key"], string> = {
|
||||||
|
aboutUs: t.footer.aboutUs,
|
||||||
|
origin: t.nav.origin,
|
||||||
|
harvests: t.nav.harvests,
|
||||||
|
contact: t.nav.contact,
|
||||||
|
};
|
||||||
|
const einkaufenLabels: Record<(typeof footerNav.einkaufen)[number]["key"], string> = {
|
||||||
|
shop: t.nav.shop,
|
||||||
|
businessCustomers: t.footer.businessCustomers,
|
||||||
|
trackOrder: t.footer.trackOrder,
|
||||||
|
};
|
||||||
|
const rechtlichesLabels: Record<(typeof footerNav.rechtliches)[number]["key"], string> = {
|
||||||
|
impressum: t.legalTitles.impressum,
|
||||||
|
datenschutz: t.legalTitles.datenschutz,
|
||||||
|
agb: t.legalTitles.agb,
|
||||||
|
widerruf: t.legalTitles.widerruf,
|
||||||
|
versandZahlung: t.legalTitles.versandZahlung,
|
||||||
|
cookieRichtlinie: t.legalTitles.cookieRichtlinie,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-forest text-cream mt-24">
|
||||||
|
<Container className="py-16">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Logomark className="h-9 w-9" variant="inverted" />
|
||||||
|
<span className="text-2xl tracking-[0.2em] font-serif">
|
||||||
|
{companyInfo.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-cream/70 text-sm max-w-xs">{t.footer.tagline}</p>
|
||||||
|
<p className="mt-6 text-cream/60 text-sm">
|
||||||
|
{companyInfo.address.street}
|
||||||
|
<br />
|
||||||
|
{companyInfo.address.zip} {companyInfo.address.city}
|
||||||
|
<br />
|
||||||
|
{companyInfo.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
|
||||||
|
<FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} />
|
||||||
|
<FooterColumn title={t.footer.columnLegal} links={footerNav.rechtliches} labels={rechtlichesLabels} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-16 pt-8 border-t border-cream/15 flex flex-col md:flex-row md:items-center justify-between gap-4 text-xs text-cream/70">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
|
<span>
|
||||||
|
© {FOUNDING_YEAR}–{new Date().getFullYear()} {companyInfo.name}
|
||||||
|
</span>
|
||||||
|
{hasAnySocialLink ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{socialLinks.tiktok ? (
|
||||||
|
<SocialLink href={socialLinks.tiktok} name="TikTok" icon={<TikTokIcon />} t={t} />
|
||||||
|
) : null}
|
||||||
|
{socialLinks.youtube ? (
|
||||||
|
<SocialLink href={socialLinks.youtube} name="YouTube" icon={<YouTubeIcon />} t={t} />
|
||||||
|
) : null}
|
||||||
|
{socialLinks.instagram ? (
|
||||||
|
<SocialLink href={socialLinks.instagram} name="Instagram" icon={<InstagramIcon />} t={t} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span>{t.footer.bottomLine}</span>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FooterColumn({
|
||||||
|
title,
|
||||||
|
links,
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
links: readonly { href: string; key: string }[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="tracking-wide-caps text-xs text-honey-light mb-4">{title}</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{links.map((link) => (
|
||||||
|
<li key={link.key}>
|
||||||
|
<Link href={link.href} className="text-sm text-cream/75 hover:text-cream">
|
||||||
|
{labels[link.key]}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -149,6 +149,20 @@ export const de: Dictionary = {
|
|||||||
legalPage: {
|
legalPage: {
|
||||||
reviewNotice: "[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]",
|
reviewNotice: "[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]",
|
||||||
},
|
},
|
||||||
|
notFoundPage: {
|
||||||
|
eyebrow: "404",
|
||||||
|
title: "Diese Seite gibt es nicht — zumindest nicht mehr.",
|
||||||
|
text: "Der Link ist vielleicht veraltet, oder die Seite wurde verschoben. Schau dich im Shop um oder geh zurück zur Startseite.",
|
||||||
|
backToShop: "Zum Shop",
|
||||||
|
backToHome: "Zur Startseite",
|
||||||
|
},
|
||||||
|
siteErrorPage: {
|
||||||
|
eyebrow: "Fehler",
|
||||||
|
title: "Da ist etwas schiefgelaufen.",
|
||||||
|
text: "Das war ein technischer Fehler auf unserer Seite, nicht deine Schuld. Versuch es noch einmal, oder komm später wieder vorbei.",
|
||||||
|
retry: "Erneut versuchen",
|
||||||
|
backToHome: "Zur Startseite",
|
||||||
|
},
|
||||||
cookieConsent: {
|
cookieConsent: {
|
||||||
dialogLabel: "Cookie-Einstellungen",
|
dialogLabel: "Cookie-Einstellungen",
|
||||||
bannerTextBeforeLink:
|
bannerTextBeforeLink:
|
||||||
|
|||||||
@@ -149,6 +149,20 @@ export const en: Dictionary = {
|
|||||||
legalPage: {
|
legalPage: {
|
||||||
reviewNotice: "[PLACEHOLDER — must be reviewed by a lawyer before publication]",
|
reviewNotice: "[PLACEHOLDER — must be reviewed by a lawyer before publication]",
|
||||||
},
|
},
|
||||||
|
notFoundPage: {
|
||||||
|
eyebrow: "404",
|
||||||
|
title: "This page doesn't exist — at least not anymore.",
|
||||||
|
text: "The link might be outdated, or the page has moved. Take a look around the shop, or head back to the homepage.",
|
||||||
|
backToShop: "Go to shop",
|
||||||
|
backToHome: "Back to homepage",
|
||||||
|
},
|
||||||
|
siteErrorPage: {
|
||||||
|
eyebrow: "Error",
|
||||||
|
title: "Something went wrong.",
|
||||||
|
text: "That was a technical error on our end, not something you did. Try again, or come back a bit later.",
|
||||||
|
retry: "Try again",
|
||||||
|
backToHome: "Back to homepage",
|
||||||
|
},
|
||||||
cookieConsent: {
|
cookieConsent: {
|
||||||
dialogLabel: "Cookie settings",
|
dialogLabel: "Cookie settings",
|
||||||
bannerTextBeforeLink:
|
bannerTextBeforeLink:
|
||||||
|
|||||||
@@ -172,6 +172,20 @@ export interface Dictionary {
|
|||||||
legalPage: {
|
legalPage: {
|
||||||
reviewNotice: string;
|
reviewNotice: string;
|
||||||
};
|
};
|
||||||
|
notFoundPage: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
backToShop: string;
|
||||||
|
backToHome: string;
|
||||||
|
};
|
||||||
|
siteErrorPage: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
retry: string;
|
||||||
|
backToHome: string;
|
||||||
|
};
|
||||||
cookieConsent: {
|
cookieConsent: {
|
||||||
dialogLabel: string;
|
dialogLabel: string;
|
||||||
bannerTextBeforeLink: string;
|
bannerTextBeforeLink: string;
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
|
||||||
|
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
|
||||||
|
* die die "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt
|
||||||
|
* (admin/actions/apicore-sync.ts).
|
||||||
|
*
|
||||||
|
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
|
||||||
|
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
|
||||||
|
* ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN
|
||||||
|
* API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den
|
||||||
|
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
||||||
|
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
||||||
|
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
||||||
|
* mit ausschliesslich den Berechtigungen "harvests:read" (und optional
|
||||||
|
* "apiaries:read", fuer Standortnamen) empfohlen statt eines echten
|
||||||
|
* Admin-Zugangs.
|
||||||
|
*
|
||||||
|
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
||||||
|
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
||||||
|
* "api" laut ApiCore-Backend/src/app.config.ts)
|
||||||
|
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
|
||||||
|
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
||||||
|
*
|
||||||
|
* Ohne diese drei Variablen wirft fetchApiCoreHarvests() sofort einen
|
||||||
|
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
|
||||||
|
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ApiCoreConfigError extends Error {}
|
||||||
|
export class ApiCoreRequestError extends Error {}
|
||||||
|
|
||||||
|
const loginResponseSchema = z.union([
|
||||||
|
z.object({ accessToken: z.string().min(1) }),
|
||||||
|
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const apiarySchema = z.object({ id: z.string(), name: z.string() });
|
||||||
|
|
||||||
|
const harvestSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
apiaryId: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
honeyType: z.string().nullable().optional(),
|
||||||
|
honeySuperCount: z.number().nullable().optional(),
|
||||||
|
grossWeight: z.number().nullable().optional(),
|
||||||
|
netWeight: z.number().nullable().optional(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
||||||
|
|
||||||
|
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
|
||||||
|
apiaryName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig(): { baseUrl: string; email: string; password: string } {
|
||||||
|
const baseUrl = process.env.APICORE_API_URL;
|
||||||
|
const email = process.env.APICORE_SYNC_EMAIL;
|
||||||
|
const password = process.env.APICORE_SYNC_PASSWORD;
|
||||||
|
if (!baseUrl || !email || !password) {
|
||||||
|
throw new ApiCoreConfigError(
|
||||||
|
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(baseUrl: string, email: string, password: string): Promise<string> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${baseUrl}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
const parsed = loginResponseSchema.safeParse(await res.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new ApiCoreRequestError("ApiCore-Login: unerwartete Antwortstruktur.");
|
||||||
|
}
|
||||||
|
if ("mfaRequired" in parsed.data) {
|
||||||
|
throw new ApiCoreConfigError(
|
||||||
|
"Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed.data.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
|
||||||
|
async function fetchApiaryNames(baseUrl: string, token: string): Promise<Map<string, string>> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
if (!res.ok) return new Map();
|
||||||
|
const parsed = z.array(apiarySchema).safeParse(await res.json());
|
||||||
|
if (!parsed.success) return new Map();
|
||||||
|
return new Map(parsed.data.map((a) => [a.id, a.name]));
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryName[]> {
|
||||||
|
const { baseUrl, email, password } = getConfig();
|
||||||
|
const token = await login(baseUrl, email, password);
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiCoreRequestError(`ApiCore: Ernten konnten nicht geladen werden (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
const parsed = z.array(harvestSchema).safeParse(await res.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiaryNames = await fetchApiaryNames(baseUrl, token);
|
||||||
|
return parsed.data.map((harvest) => ({
|
||||||
|
...harvest,
|
||||||
|
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
|||||||
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
|
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
|
||||||
|
|
||||||
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
|
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
|
||||||
// angezeigt, siehe components/site-chrome.tsx Footer()) ---
|
// angezeigt, siehe components/site-footer.tsx Footer()) ---
|
||||||
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
||||||
{ key: "social.youtube", group: "social", labelDe: "YouTube-Kanal (URL)", labelEn: "YouTube channel (URL)", type: "text", localized: false, getDefault: () => "" },
|
{ key: "social.youtube", group: "social", labelDe: "YouTube-Kanal (URL)", labelEn: "YouTube channel (URL)", type: "text", localized: false, getDefault: () => "" },
|
||||||
{ key: "social.instagram", group: "social", labelDe: "Instagram-Profil (URL)", labelEn: "Instagram profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
{ key: "social.instagram", group: "social", labelDe: "Instagram-Profil (URL)", labelEn: "Instagram profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { isTrustedOrigin } from "./csrf";
|
||||||
|
|
||||||
|
function makeRequest(url: string, origin?: string): Request {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (origin !== undefined) headers.set("origin", origin);
|
||||||
|
return new Request(url, { method: "POST", headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isTrustedOrigin", () => {
|
||||||
|
test("accepts a same-origin request", () => {
|
||||||
|
const request = makeRequest("https://wabenhain.com/api/checkout", "https://wabenhain.com");
|
||||||
|
expect(isTrustedOrigin(request)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a same-origin request even with an explicit default port in the URL", () => {
|
||||||
|
const request = makeRequest("https://wabenhain.com:443/api/checkout", "https://wabenhain.com");
|
||||||
|
expect(isTrustedOrigin(request)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a cross-origin request (forged CSRF attempt)", () => {
|
||||||
|
const request = makeRequest("https://wabenhain.com/api/checkout", "https://attacker.example");
|
||||||
|
expect(isTrustedOrigin(request)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a cross-origin request even from a similarly named host", () => {
|
||||||
|
const request = makeRequest("https://wabenhain.com/api/checkout", "https://wabenhain.com.attacker.example");
|
||||||
|
expect(isTrustedOrigin(request)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a malformed Origin header instead of throwing", () => {
|
||||||
|
const request = makeRequest("https://wabenhain.com/api/checkout", "not-a-valid-url");
|
||||||
|
expect(isTrustedOrigin(request)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a request with no Origin header (e.g. server-to-server calls)", () => {
|
||||||
|
const request = makeRequest("https://wabenhain.com/api/checkout");
|
||||||
|
expect(isTrustedOrigin(request)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Same-Origin-Pruefung fuer zustandsaendernde app/api/.../route.ts-Handler.
|
||||||
|
*
|
||||||
|
* Next.js' eingebauter CSRF-Schutz (Origin-Header gegen Host geprueft) gilt
|
||||||
|
* nur fuer Server Actions ("use server") — eigene Route-Handler wie
|
||||||
|
* /api/checkout sind normale POST-Endpunkte ohne diesen automatischen Schutz
|
||||||
|
* und muessen ihn sich selbst holen. Ohne diese Pruefung liesse sich ein
|
||||||
|
* state-aendernder POST z.B. per <form enctype="text/plain"> als "einfacher"
|
||||||
|
* (nicht preflight-pflichtiger) Cross-Origin-Request faelschen (bekannte
|
||||||
|
* JSON-CSRF-Umgehung) — bei /api/checkout koennte das, sofern der Besuch von
|
||||||
|
* einer eingeloggten Kundschaft ausgeht, Bestellungen unter deren Konto
|
||||||
|
* anlegen und Lagerbestand reservieren, ohne dass die Kundschaft das wollte.
|
||||||
|
*/
|
||||||
|
export function isTrustedOrigin(request: Request): boolean {
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
// Kein Origin-Header: moderne Browser senden ihn bei POST/PUT/DELETE sowohl
|
||||||
|
// fuer Cross-Origin- als auch fuer Same-Origin-Anfragen mit, ein fehlender
|
||||||
|
// Header stammt also nicht von einem regulaeren browserbasierten
|
||||||
|
// Cross-Site-Angriff. Wird hier bewusst durchgelassen, um serverseitige/
|
||||||
|
// Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren.
|
||||||
|
if (!origin) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestHost = new URL(request.url).host;
|
||||||
|
const originHost = new URL(origin).host;
|
||||||
|
return requestHost === originHost;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
const ENV_KEYS = ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] as const;
|
||||||
|
const originalEnv: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
function configureDhl(): void {
|
||||||
|
process.env.DHL_API_KEY = "api-key";
|
||||||
|
process.env.DHL_API_SECRET = "api-secret";
|
||||||
|
process.env.DHL_GKP_USERNAME = "gkp-user";
|
||||||
|
process.env.DHL_GKP_PASSWORD = "gkp-pass";
|
||||||
|
process.env.DHL_ACCOUNT_NUMBER = "33333333330101";
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validAddress = {
|
||||||
|
firstName: "Erika",
|
||||||
|
lastName: "Musterfrau",
|
||||||
|
street: "Musterstraße 12a",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Musterstadt",
|
||||||
|
country: "DE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const validShipper = { ...validAddress, companyName: "Wabenhain" };
|
||||||
|
|
||||||
|
// dhl.ts haelt das OAuth2-Token in einem modulweiten In-Memory-Cache (siehe
|
||||||
|
// Kommentar dort) — vi.resetModules() + dynamischer Import pro Test stellt
|
||||||
|
// sicher, dass kein Token zwischen Tests "durchsickert" und einen erwarteten
|
||||||
|
// Token-Request-Mock stillschweigend ueberspringt.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
originalEnv[key] = process.env[key];
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
delete process.env.DHL_ENVIRONMENT;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
if (originalEnv[key] === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = originalEnv[key];
|
||||||
|
}
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isDhlConfigured", () => {
|
||||||
|
test("is false when env vars are missing", async () => {
|
||||||
|
const { isDhlConfigured } = await import("./dhl");
|
||||||
|
expect(isDhlConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is true once all required env vars are set", async () => {
|
||||||
|
configureDhl();
|
||||||
|
const { isDhlConfigured } = await import("./dhl");
|
||||||
|
expect(isDhlConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDhlShipmentLabel", () => {
|
||||||
|
test("throws DhlNotConfiguredError instead of calling the network when unconfigured", async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const { createDhlShipmentLabel, DhlNotConfiguredError } = await import("./dhl");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createDhlShipmentLabel({
|
||||||
|
reference: "WH-2026-0001",
|
||||||
|
weightGrams: 500,
|
||||||
|
shipper: validShipper,
|
||||||
|
receiver: validAddress,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(DhlNotConfiguredError);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("splits the street/house-number, maps the ISO2 country to ISO3, and returns the tracking number + label on success", async () => {
|
||||||
|
configureDhl();
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
// 1. Token-Aufruf
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }))
|
||||||
|
// 2. Sendungserstellung
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
items: [{ shipmentNo: "00340434161094123456", label: { b64: "cGRmLWJ5dGVz" } }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const { createDhlShipmentLabel } = await import("./dhl");
|
||||||
|
|
||||||
|
const result = await createDhlShipmentLabel({
|
||||||
|
reference: "WH-2026-0001",
|
||||||
|
weightGrams: 500,
|
||||||
|
shipper: validShipper,
|
||||||
|
receiver: validAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ trackingNumber: "00340434161094123456", labelPdfBase64: "cGRmLWJ5dGVz" });
|
||||||
|
|
||||||
|
// Zweiter fetch-Aufruf ist die eigentliche Sendungserstellung — Body
|
||||||
|
// pruefen, dass Adress-Mapping korrekt durchgereicht wurde.
|
||||||
|
const shipmentCall = fetchMock.mock.calls[1];
|
||||||
|
const requestBody = JSON.parse(shipmentCall[1].body as string);
|
||||||
|
const consignee = requestBody.shipments[0].consignee;
|
||||||
|
expect(consignee.address.addressStreet).toBe("Musterstraße");
|
||||||
|
expect(consignee.address.addressHouse).toBe("12a");
|
||||||
|
expect(consignee.address.country).toBe("DEU");
|
||||||
|
expect(shipmentCall[1].headers["dhl-api-key"]).toBe("api-key");
|
||||||
|
expect(shipmentCall[1].headers["Authorization"]).toBe("Bearer token-abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an unsupported destination country instead of guessing a code", async () => {
|
||||||
|
configureDhl();
|
||||||
|
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createDhlShipmentLabel({
|
||||||
|
reference: "WH-2026-0002",
|
||||||
|
weightGrams: 500,
|
||||||
|
shipper: validShipper,
|
||||||
|
receiver: { ...validAddress, country: "US" },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(DhlApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces a DHL-side validation error instead of returning a fake success", async () => {
|
||||||
|
configureDhl();
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
validationMessages: [{ validationState: "Error", validationMessage: "postalCode ungültig" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createDhlShipmentLabel({
|
||||||
|
reference: "WH-2026-0003",
|
||||||
|
weightGrams: 500,
|
||||||
|
shipper: validShipper,
|
||||||
|
receiver: validAddress,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(DhlApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces an HTTP error from the DHL API with its detail message", async () => {
|
||||||
|
configureDhl();
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ access_token: "token-abc", expires_in: 3600 }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ detail: "Ungültige Anfrage" }, 400));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createDhlShipmentLabel({
|
||||||
|
reference: "WH-2026-0004",
|
||||||
|
weightGrams: 500,
|
||||||
|
shipper: validShipper,
|
||||||
|
receiver: validAddress,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Ungültige Anfrage/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Client fuer die DHL Parcel DE Shipping API v2 ("Post & Parcel Germany",
|
||||||
|
* developer.dhl.com) — erstellt Versandmarken (Labels) fuer nationale
|
||||||
|
* DHL-Paketsendungen.
|
||||||
|
*
|
||||||
|
* Authentifizierung laut aktueller DHL-Dokumentation (Stand: Recherche
|
||||||
|
* September 2026, developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2):
|
||||||
|
* Zwei Faktoren sind noetig, nicht nur ein API-Key:
|
||||||
|
* 1. Ein OAuth2-Access-Token ueber den ROPC-Grant (Resource Owner Password
|
||||||
|
* Credentials) unter POST /parcel/de/account/auth/ropc/v1/token, mit
|
||||||
|
* App-Client-ID/-Secret UND den Zugangsdaten des DHL-Geschaeftskunden-
|
||||||
|
* portals (GKP-Benutzername/-Passwort) im Body.
|
||||||
|
* 2. Der App-API-Key zusaetzlich als "dhl-api-key"-Header auf jedem
|
||||||
|
* eigentlichen API-Aufruf (identifiziert die registrierte App/Quota).
|
||||||
|
* DHL kuendigt an, das aeltere reine Basic-Auth-Schema mittelfristig
|
||||||
|
* abzuschaffen — dieser Client verwendet daher bewusst den OAuth2/ROPC-Weg.
|
||||||
|
*
|
||||||
|
* Sandbox-Basis-URL: https://api-sandbox.dhl.com
|
||||||
|
* Produktions-Basis-URL: https://api-eu.dhl.com
|
||||||
|
*
|
||||||
|
* WICHTIG: Die genauen Feldnamen/Endpunkte wurden anhand der oeffentlichen
|
||||||
|
* API-Referenz und des zugehoerigen OpenAPI-Schemas recherchiert, konnten in
|
||||||
|
* dieser Umgebung aber nicht gegen einen echten Sandbox-Account getestet
|
||||||
|
* werden (keine DHL-Zugangsdaten vorhanden). Vor dem produktiven Einsatz:
|
||||||
|
* einmal mit echten Sandbox-Zugangsdaten (siehe .env.example) gegen
|
||||||
|
* https://developer.dhl.com testen und bei Abweichungen die Request-/
|
||||||
|
* Response-Shape unten anpassen — DHL liefert bei Validierungsfehlern
|
||||||
|
* sprechende Fehlermeldungen, die per DhlApiError durchgereicht werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fehlerklassen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Wird geworfen, wenn eine/mehrere der benoetigten DHL_*-Umgebungsvariablen fehlen. */
|
||||||
|
export class DhlNotConfiguredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"DHL ist noch nicht konfiguriert. Bitte DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, " +
|
||||||
|
"DHL_GKP_PASSWORD und DHL_ACCOUNT_NUMBER in der .env-Datei setzen (siehe .env.example)."
|
||||||
|
);
|
||||||
|
this.name = "DhlNotConfiguredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wird bei einem fehlgeschlagenen Aufruf der DHL-API geworfen (Auth, Validierung, Netzwerk, ...). */
|
||||||
|
export class DhlApiError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DhlApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Konfiguration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DhlConfig {
|
||||||
|
apiKey: string;
|
||||||
|
apiSecret: string;
|
||||||
|
gkpUsername: string;
|
||||||
|
gkpPassword: string;
|
||||||
|
/**
|
||||||
|
* "billingNumber" — 14-stellig: 10-stellige EKP (Kundennummer) + 2-stelliges
|
||||||
|
* Verfahren + 2-stellige Teilnahme, z.B. "33333333330101" fuer V01PAK.
|
||||||
|
* Wird vom DHL-Vertrieb im Rahmen des Geschaeftskundenvertrags vergeben.
|
||||||
|
*/
|
||||||
|
accountNumber: string;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDhlConfig(): DhlConfig {
|
||||||
|
const apiKey = process.env.DHL_API_KEY;
|
||||||
|
const apiSecret = process.env.DHL_API_SECRET;
|
||||||
|
const gkpUsername = process.env.DHL_GKP_USERNAME;
|
||||||
|
const gkpPassword = process.env.DHL_GKP_PASSWORD;
|
||||||
|
const accountNumber = process.env.DHL_ACCOUNT_NUMBER;
|
||||||
|
|
||||||
|
if (!apiKey || !apiSecret || !gkpUsername || !gkpPassword || !accountNumber) {
|
||||||
|
throw new DhlNotConfiguredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = (process.env.DHL_ENVIRONMENT ?? "sandbox").trim().toLowerCase();
|
||||||
|
const baseUrl = environment === "production" ? "https://api-eu.dhl.com" : "https://api-sandbox.dhl.com";
|
||||||
|
|
||||||
|
return { apiKey, apiSecret, gkpUsername, gkpPassword, accountNumber, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer die bedingte Anzeige/Deaktivierung des "Versandmarke erstellen"-Buttons im Admin-UI. */
|
||||||
|
export function isDhlConfigured(): boolean {
|
||||||
|
try {
|
||||||
|
getDhlConfig();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OAuth2-Access-Token (ROPC), pro Serverprozess zwischengespeichert
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CachedToken {
|
||||||
|
accessToken: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modul-weiter In-Memory-Cache: analog zu lib/rate-limit.ts ausreichend fuer
|
||||||
|
// den Single-Container-Betrieb dieser Seite. Bei horizontaler Skalierung
|
||||||
|
// wuerde jede Instanz ihr eigenes Token ziehen, was fuer DHL unproblematisch
|
||||||
|
// ist (kein geteilter Zustand noetig).
|
||||||
|
let cachedToken: CachedToken | null = null;
|
||||||
|
|
||||||
|
async function fetchAccessToken(config: DhlConfig): Promise<string> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${config.baseUrl}/parcel/de/account/auth/ropc/v1/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "password",
|
||||||
|
username: config.gkpUsername,
|
||||||
|
password: config.gkpPassword,
|
||||||
|
client_id: config.apiKey,
|
||||||
|
client_secret: config.apiSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||||
|
throw new DhlApiError(`DHL-Authentifizierung nicht erreichbar: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await safeReadJson(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new DhlApiError(
|
||||||
|
`DHL-Authentifizierung fehlgeschlagen (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = typeof payload?.access_token === "string" ? payload.access_token : null;
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new DhlApiError("DHL-Authentifizierung lieferte kein Access Token zurück.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInSeconds = typeof payload?.expires_in === "number" ? payload.expires_in : 3600;
|
||||||
|
// 60 Sekunden Sicherheitsabstand vor dem tatsaechlichen Ablauf, damit kein
|
||||||
|
// knapp abgelaufenes Token noch fuer einen Request verwendet wird.
|
||||||
|
cachedToken = { accessToken, expiresAt: Date.now() + Math.max(expiresInSeconds - 60, 30) * 1000 };
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessToken(config: DhlConfig): Promise<string> {
|
||||||
|
if (cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken.accessToken;
|
||||||
|
return fetchAccessToken(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Adress-Mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DHL erwartet Laendercodes nach ISO 3166-1 ALPHA-3, waehrend Order.country
|
||||||
|
// in diesem Projekt ALPHA-2 speichert (siehe prisma/schema.prisma). Bewusst
|
||||||
|
// nur die in einem deutschen Honig-Versandhandel realistischen Zielmaerkte
|
||||||
|
// gelistet — ein nicht gelistetes Land liefert einen klaren Fehler statt
|
||||||
|
// eines falsch geratenen Codes.
|
||||||
|
const COUNTRY_ISO2_TO_ISO3: Record<string, string> = {
|
||||||
|
DE: "DEU",
|
||||||
|
AT: "AUT",
|
||||||
|
CH: "CHE",
|
||||||
|
FR: "FRA",
|
||||||
|
NL: "NLD",
|
||||||
|
BE: "BEL",
|
||||||
|
LU: "LUX",
|
||||||
|
IT: "ITA",
|
||||||
|
ES: "ESP",
|
||||||
|
PL: "POL",
|
||||||
|
DK: "DNK",
|
||||||
|
CZ: "CZE",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toIso3Country(iso2: string): string {
|
||||||
|
const code = COUNTRY_ISO2_TO_ISO3[iso2.trim().toUpperCase()];
|
||||||
|
if (!code) {
|
||||||
|
throw new DhlApiError(
|
||||||
|
`Land "${iso2}" wird vom DHL-Client aktuell nicht unterstützt (siehe COUNTRY_ISO2_TO_ISO3 in lib/dhl.ts).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trennt eine deutsche Adresszeile ("Musterstraße 12a") in Straßenname und
|
||||||
|
* Hausnummer fuer die getrennten DHL-Adressfelder (addressStreet/addressHouse).
|
||||||
|
* Ohne erkennbare Hausnummer am Ende landet die komplette Zeile im
|
||||||
|
* Straßenfeld — das Hausnummernfeld ist bei DHL optional, ein falscher
|
||||||
|
* Trennversuch waere schlechter als gar keine Trennung.
|
||||||
|
*/
|
||||||
|
function splitStreet(street: string): { addressStreet: string; addressHouse?: string } {
|
||||||
|
const trimmed = street.trim();
|
||||||
|
const match = trimmed.match(/^(.*\S)\s+(\d+\s*[a-zA-Z]?)$/);
|
||||||
|
if (!match) return { addressStreet: trimmed };
|
||||||
|
return { addressStreet: match[1], addressHouse: match[2].replace(/\s+/g, "") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Versandmarke erstellen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DhlAddressInput {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
street: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
/** ISO 3166-1 ALPHA-2, z.B. "DE" — wie Order.country in prisma/schema.prisma. */
|
||||||
|
country: string;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DhlShipperInput extends DhlAddressInput {
|
||||||
|
companyName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DhlCreateShipmentInput {
|
||||||
|
/** Wird als DHL-Referenz (refNo) mitgeschickt, z.B. die Wabenhain-Bestellnummer. */
|
||||||
|
reference: string;
|
||||||
|
/** Gesamtgewicht der Sendung in Gramm (Summe aller Positionen inkl. Menge). */
|
||||||
|
weightGrams: number;
|
||||||
|
shipper: DhlShipperInput;
|
||||||
|
receiver: DhlAddressInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DhlCreateShipmentResult {
|
||||||
|
trackingNumber: string;
|
||||||
|
/** Base64-kodiertes PDF-Label — wird aktuell nicht persistiert (siehe README/Bericht). */
|
||||||
|
labelPdfBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deutsches Standard-Inlandspaket. Andere Produkte (z.B. Warenpost/Kleinpaket
|
||||||
|
// fuer kleine, leichte Sendungen) sind bewusst nicht abgebildet — YAGNI,
|
||||||
|
// bis der Bedarf konkret entsteht.
|
||||||
|
const DHL_PRODUCT_NATIONAL_PARCEL = "V01PAK";
|
||||||
|
|
||||||
|
function buildDhlContact(address: DhlAddressInput, name1: string) {
|
||||||
|
const { addressStreet, addressHouse } = splitStreet(address.street);
|
||||||
|
return {
|
||||||
|
name1,
|
||||||
|
phone: address.phone || undefined,
|
||||||
|
email: address.email || undefined,
|
||||||
|
address: {
|
||||||
|
addressStreet,
|
||||||
|
addressHouse,
|
||||||
|
postalCode: address.zip,
|
||||||
|
city: address.city,
|
||||||
|
country: toIso3Country(address.country),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDhlShipmentLabel(
|
||||||
|
input: DhlCreateShipmentInput
|
||||||
|
): Promise<DhlCreateShipmentResult> {
|
||||||
|
const config = getDhlConfig();
|
||||||
|
const accessToken = await getAccessToken(config);
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
shipments: [
|
||||||
|
{
|
||||||
|
product: DHL_PRODUCT_NATIONAL_PARCEL,
|
||||||
|
billingNumber: config.accountNumber,
|
||||||
|
// DHL erlaubt max. 35 Zeichen fuer refNo.
|
||||||
|
refNo: input.reference.slice(0, 35),
|
||||||
|
shipper: buildDhlContact(input.shipper, input.shipper.companyName),
|
||||||
|
consignee: buildDhlContact(input.receiver, `${input.receiver.firstName} ${input.receiver.lastName}`.trim()),
|
||||||
|
details: {
|
||||||
|
weight: { uom: "g", value: Math.max(Math.round(input.weightGrams), 1) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${config.baseUrl}/parcel/de/shipping/v2/orders`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"dhl-api-key": config.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||||
|
throw new DhlApiError(`DHL-API nicht erreichbar: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await safeReadJson(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new DhlApiError(
|
||||||
|
`DHL-Versandmarke konnte nicht erstellt werden (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = Array.isArray(payload?.items) ? payload.items[0] : undefined;
|
||||||
|
const validationError = Array.isArray(item?.validationMessages)
|
||||||
|
? item.validationMessages.find((entry: { validationState?: string }) => entry?.validationState === "Error")
|
||||||
|
: undefined;
|
||||||
|
if (validationError) {
|
||||||
|
throw new DhlApiError(`DHL meldet einen Fehler in den Sendungsdaten: ${validationError.validationMessage ?? "unbekannt"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackingNumber = typeof item?.shipmentNo === "string" ? item.shipmentNo : null;
|
||||||
|
const labelPdfBase64 = typeof item?.label?.b64 === "string" ? item.label.b64 : null;
|
||||||
|
if (!trackingNumber || !labelPdfBase64) {
|
||||||
|
throw new DhlApiError("DHL-Antwort enthielt keine Sendungsnummer oder kein Label — bitte Sandbox-Response pruefen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trackingNumber, labelPdfBase64 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hilfsfunktionen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function safeReadJson(response: Response): Promise<Record<string, unknown> | undefined> {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrahiert eine lesbare Fehlermeldung aus gaengigen DHL-Fehlerformaten (RFC7807-aehnlich). */
|
||||||
|
function extractErrorDetail(payload: Record<string, unknown> | undefined): string | undefined {
|
||||||
|
if (!payload) return undefined;
|
||||||
|
if (typeof payload.detail === "string") return payload.detail;
|
||||||
|
if (typeof payload.title === "string") return payload.title;
|
||||||
|
if (typeof payload.error_description === "string") return payload.error_description;
|
||||||
|
if (typeof payload.message === "string") return payload.message;
|
||||||
|
const status = payload.status as { detail?: string; title?: string } | undefined;
|
||||||
|
if (status?.detail) return status.detail;
|
||||||
|
if (status?.title) return status.title;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
generatePkcePair,
|
||||||
|
generateOAuthState,
|
||||||
|
buildEntraAuthorizeUrl,
|
||||||
|
getVerifiedEmail,
|
||||||
|
isEntraConfigured,
|
||||||
|
EntraNotConfiguredError,
|
||||||
|
} from "./entra";
|
||||||
|
|
||||||
|
const ENV_KEYS = ["ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET", "ENTRA_REDIRECT_URI"] as const;
|
||||||
|
const originalEnv: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
originalEnv[key] = process.env[key];
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
if (originalEnv[key] === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = originalEnv[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function configureEntra(): void {
|
||||||
|
process.env.ENTRA_TENANT_ID = "tenant-123";
|
||||||
|
process.env.ENTRA_CLIENT_ID = "client-456";
|
||||||
|
process.env.ENTRA_CLIENT_SECRET = "secret-789";
|
||||||
|
process.env.ENTRA_REDIRECT_URI = "https://wabenhain.com/api/auth/entra/callback";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generatePkcePair", () => {
|
||||||
|
test("produces a code_verifier in the RFC 7636 charset and length range", () => {
|
||||||
|
const { codeVerifier } = generatePkcePair();
|
||||||
|
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives code_challenge as base64url(SHA256(code_verifier))", () => {
|
||||||
|
const { codeVerifier, codeChallenge } = generatePkcePair();
|
||||||
|
const expected = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||||
|
expect(codeChallenge).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates a different pair on every call (not reused across requests)", () => {
|
||||||
|
const a = generatePkcePair();
|
||||||
|
const b = generatePkcePair();
|
||||||
|
expect(a.codeVerifier).not.toBe(b.codeVerifier);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateOAuthState", () => {
|
||||||
|
test("generates a non-empty, URL-safe, unpredictable value each call", () => {
|
||||||
|
const a = generateOAuthState();
|
||||||
|
const b = generateOAuthState();
|
||||||
|
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
expect(a.length).toBeGreaterThanOrEqual(20);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isEntraConfigured / buildEntraAuthorizeUrl", () => {
|
||||||
|
test("reports not configured when env vars are missing", () => {
|
||||||
|
expect(isEntraConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildEntraAuthorizeUrl throws EntraNotConfiguredError when unconfigured", () => {
|
||||||
|
expect(() => buildEntraAuthorizeUrl({ state: "s", codeChallenge: "c" })).toThrow(EntraNotConfiguredError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports configured and builds a correct authorize URL once env vars are set", () => {
|
||||||
|
configureEntra();
|
||||||
|
expect(isEntraConfigured()).toBe(true);
|
||||||
|
|
||||||
|
const url = new URL(buildEntraAuthorizeUrl({ state: "my-state", codeChallenge: "my-challenge" }));
|
||||||
|
expect(url.origin + url.pathname).toBe("https://login.microsoftonline.com/tenant-123/oauth2/v2.0/authorize");
|
||||||
|
expect(url.searchParams.get("client_id")).toBe("client-456");
|
||||||
|
expect(url.searchParams.get("redirect_uri")).toBe("https://wabenhain.com/api/auth/entra/callback");
|
||||||
|
expect(url.searchParams.get("response_type")).toBe("code");
|
||||||
|
expect(url.searchParams.get("state")).toBe("my-state");
|
||||||
|
expect(url.searchParams.get("code_challenge")).toBe("my-challenge");
|
||||||
|
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||||
|
// "openid" ist zwingend fuer OIDC, "email" liefert den verifizierten
|
||||||
|
// E-Mail-Claim, den getVerifiedEmail() unten voraussetzt.
|
||||||
|
expect(url.searchParams.get("scope")).toContain("openid");
|
||||||
|
expect(url.searchParams.get("scope")).toContain("email");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getVerifiedEmail", () => {
|
||||||
|
test("returns the lowercased, trimmed email claim when present and well-formed", () => {
|
||||||
|
expect(getVerifiedEmail({ email: " [email protected] " })).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when the email claim is missing", () => {
|
||||||
|
expect(getVerifiedEmail({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when the email claim doesn't look like an email (never guesses/trusts a bad claim)", () => {
|
||||||
|
expect(getVerifiedEmail({ email: "not-an-email" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* OIDC-Client fuer "Mit Microsoft anmelden" (Entra ID / Azure AD) —
|
||||||
|
* OAuth2 Authorization Code Flow mit PKCE gegen die Microsoft Identity
|
||||||
|
* Platform, siehe learn.microsoft.com/entra/identity-platform/
|
||||||
|
* v2-oauth2-auth-code-flow und .../v2-protocols-oidc (Stand der Recherche:
|
||||||
|
* September 2026).
|
||||||
|
*
|
||||||
|
* Endpunkte (single-tenant, {tenant} = ENTRA_TENANT_ID):
|
||||||
|
* Autorisierung: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
|
||||||
|
* Token: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
|
||||||
|
* JWKS: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys
|
||||||
|
* Issuer (iss-Claim im ID-Token): https://login.microsoftonline.com/{tenant}/v2.0
|
||||||
|
*
|
||||||
|
* response_type=code (reiner Authorization-Code-Flow, kein Hybrid-Flow) —
|
||||||
|
* das ID-Token kommt ausschliesslich ueber den serverseitigen Token-
|
||||||
|
* Endpunkt zurueck (Backchannel, per client_secret authentifiziert), nicht
|
||||||
|
* ueber die Redirect-URL. Ein "nonce"-Parameter ist daher nicht noetig
|
||||||
|
* (der schuetzt beim Hybrid-/Implicit-Flow vor Replay eines im Front-
|
||||||
|
* channel/URL-Fragment sichtbaren ID-Tokens).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, createHash } from "node:crypto";
|
||||||
|
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose";
|
||||||
|
|
||||||
|
export class EntraNotConfiguredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"Microsoft-Anmeldung (SSO) ist noch nicht konfiguriert. Bitte ENTRA_TENANT_ID, ENTRA_CLIENT_ID, " +
|
||||||
|
"ENTRA_CLIENT_SECRET und ENTRA_REDIRECT_URI in der .env-Datei setzen (siehe .env.example)."
|
||||||
|
);
|
||||||
|
this.name = "EntraNotConfiguredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EntraAuthError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "EntraAuthError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntraConfig {
|
||||||
|
tenantId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntraConfig(): EntraConfig {
|
||||||
|
const tenantId = process.env.ENTRA_TENANT_ID;
|
||||||
|
const clientId = process.env.ENTRA_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.ENTRA_CLIENT_SECRET;
|
||||||
|
const redirectUri = process.env.ENTRA_REDIRECT_URI;
|
||||||
|
if (!tenantId || !clientId || !clientSecret || !redirectUri) {
|
||||||
|
throw new EntraNotConfiguredError();
|
||||||
|
}
|
||||||
|
return { tenantId, clientId, clientSecret, redirectUri };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */
|
||||||
|
export function isEntraConfigured(): boolean {
|
||||||
|
try {
|
||||||
|
getEntraConfig();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizeEndpoint(tenantId: string): string {
|
||||||
|
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
|
||||||
|
}
|
||||||
|
function tokenEndpoint(tenantId: string): string {
|
||||||
|
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||||
|
}
|
||||||
|
function issuer(tenantId: string): string {
|
||||||
|
return `https://login.microsoftonline.com/${tenantId}/v2.0`;
|
||||||
|
}
|
||||||
|
function jwksUri(tenantId: string): string {
|
||||||
|
return `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PKCE
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PkcePair {
|
||||||
|
codeVerifier: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* code_verifier: 43-128 Zeichen aus dem PKCE-erlaubten Zeichensatz (RFC 7636).
|
||||||
|
* base64url-Kodierung von 32 Zufallsbytes ergibt 43 Zeichen aus exakt diesem
|
||||||
|
* Zeichensatz ([A-Za-z0-9_-]) — kein zusaetzlicher Zeichensatz-Filter noetig.
|
||||||
|
* code_challenge = base64url(SHA256(code_verifier)), Methode "S256".
|
||||||
|
*/
|
||||||
|
export function generatePkcePair(): PkcePair {
|
||||||
|
const codeVerifier = randomBytes(32).toString("base64url");
|
||||||
|
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||||
|
return { codeVerifier, codeChallenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateOAuthState(): string {
|
||||||
|
return randomBytes(24).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Redirect zu Microsoft
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function buildEntraAuthorizeUrl(params: { state: string; codeChallenge: string }): string {
|
||||||
|
const config = getEntraConfig();
|
||||||
|
const url = new URL(authorizeEndpoint(config.tenantId));
|
||||||
|
url.searchParams.set("client_id", config.clientId);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("redirect_uri", config.redirectUri);
|
||||||
|
url.searchParams.set("response_mode", "query");
|
||||||
|
// "email" liefert den verifizierten E-Mail-Claim, "profile" Vor-/Nachname
|
||||||
|
// fuer die Konto-Erstellung. Kein "offline_access" — wir brauchen keinen
|
||||||
|
// Refresh-Token, es findet kein weiterer API-Zugriff im Namen des Nutzers statt.
|
||||||
|
url.searchParams.set("scope", "openid profile email");
|
||||||
|
url.searchParams.set("state", params.state);
|
||||||
|
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||||
|
url.searchParams.set("code_challenge_method", "S256");
|
||||||
|
// Zeigt immer den Konto-Auswahldialog statt automatischem Single-Sign-On
|
||||||
|
// auf ein evtl. im Browser noch aktives fremdes Microsoft-Konto.
|
||||||
|
url.searchParams.set("prompt", "select_account");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Code gegen Tokens tauschen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
id_token: string;
|
||||||
|
access_token?: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeEntraAuthorizationCode(params: {
|
||||||
|
code: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
}): Promise<TokenResponse> {
|
||||||
|
const config = getEntraConfig();
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(tokenEndpoint(config.tenantId), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: params.code,
|
||||||
|
redirect_uri: config.redirectUri,
|
||||||
|
code_verifier: params.codeVerifier,
|
||||||
|
scope: "openid profile email",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||||
|
throw new EntraAuthError(`Microsoft-Token-Endpunkt nicht erreichbar: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
payload = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail =
|
||||||
|
(typeof payload?.error_description === "string" && payload.error_description) ||
|
||||||
|
(typeof payload?.error === "string" && payload.error) ||
|
||||||
|
`HTTP ${response.status}`;
|
||||||
|
throw new EntraAuthError(`Microsoft-Anmeldung fehlgeschlagen: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || typeof payload.id_token !== "string") {
|
||||||
|
throw new EntraAuthError("Microsoft-Antwort enthielt kein ID-Token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id_token: payload.id_token,
|
||||||
|
access_token: typeof payload.access_token === "string" ? payload.access_token : undefined,
|
||||||
|
token_type: typeof payload.token_type === "string" ? payload.token_type : undefined,
|
||||||
|
expires_in: typeof payload.expires_in === "number" ? payload.expires_in : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. ID-Token verifizieren (Signatur + Claims) — NIE ungeprueft vertrauen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface EntraIdTokenClaims extends JWTPayload {
|
||||||
|
email?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
name?: string;
|
||||||
|
oid?: string;
|
||||||
|
tid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein JWKSet pro Tenant reicht (dieses Projekt bindet genau einen Tenant an) —
|
||||||
|
// jose cached/erneuert die Schluessel intern automatisch (Key-Rollover).
|
||||||
|
let cachedJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||||
|
let cachedJwksTenantId: string | null = null;
|
||||||
|
|
||||||
|
function getJwks(tenantId: string): ReturnType<typeof createRemoteJWKSet> {
|
||||||
|
if (cachedJwks && cachedJwksTenantId === tenantId) return cachedJwks;
|
||||||
|
cachedJwks = createRemoteJWKSet(new URL(jwksUri(tenantId)));
|
||||||
|
cachedJwksTenantId = tenantId;
|
||||||
|
return cachedJwks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifiziert Signatur, issuer, audience und Ablauf des ID-Tokens gegen
|
||||||
|
* Microsofts oeffentliche Signaturschluessel (JWKS) — niemals ein Token
|
||||||
|
* ungeprueft dekodieren und seinen Claims vertrauen, sonst koennte jeder ein
|
||||||
|
* beliebiges Konto durch ein selbstgebasteltes JWT vortaeuschen. Gibt bei
|
||||||
|
* Erfolg die geprueften Claims zurueck, wirft sonst EntraAuthError.
|
||||||
|
*/
|
||||||
|
export async function verifyEntraIdToken(idToken: string): Promise<EntraIdTokenClaims> {
|
||||||
|
const config = getEntraConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify<EntraIdTokenClaims>(idToken, getJwks(config.tenantId), {
|
||||||
|
issuer: issuer(config.tenantId),
|
||||||
|
audience: config.clientId,
|
||||||
|
algorithms: ["RS256"],
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
throw new EntraAuthError(`ID-Token-Prüfung fehlgeschlagen: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert die verifizierte E-Mail-Adresse aus den ID-Token-Claims.
|
||||||
|
* Bewusst NUR der "email"-Claim (aus dem signierten Token, nie ein vom
|
||||||
|
* Client mitgeschicktes Feld) — "preferred_username" ist bei Microsoft-
|
||||||
|
* Konten zwar meist ebenfalls die E-Mail/UPN, aber laut Microsofts eigener
|
||||||
|
* Dokumentation nicht garantiert ident/verifiziert und kann sich zudem
|
||||||
|
* aendern. Fehlt der email-Claim (z.B. Konto ohne hinterlegte Mailbox),
|
||||||
|
* wird die Anmeldung bewusst abgelehnt statt zu raten.
|
||||||
|
*/
|
||||||
|
export function getVerifiedEmail(claims: EntraIdTokenClaims): string | null {
|
||||||
|
if (typeof claims.email === "string" && claims.email.includes("@")) {
|
||||||
|
return claims.email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -29,9 +29,10 @@ export const siteConfig = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// "key" verweist auf i18n/dictionary-type.ts (nav.* bzw. footer.*/legalTitles.*)
|
// "key" verweist auf i18n/dictionary-type.ts (nav.* bzw. footer.*/legalTitles.*)
|
||||||
// statt eines fest verdrahteten Labels — site-chrome.tsx loest den Text zur
|
// statt eines fest verdrahteten Labels — site-chrome.tsx (Header/CartDrawer)
|
||||||
// Laufzeit aus der aktiven Dictionary auf, damit Navigation und Footer mit
|
// bzw. site-footer.tsx (Footer) loesen den Text zur Laufzeit aus der aktiven
|
||||||
// der Sprachumschaltung mitziehen.
|
// Dictionary auf, damit Navigation und Footer mit der Sprachumschaltung
|
||||||
|
// mitziehen.
|
||||||
export const mainNav = [
|
export const mainNav = [
|
||||||
{ href: "/shop", key: "shop" },
|
{ href: "/shop", key: "shop" },
|
||||||
{ href: "/ernten", key: "harvests" },
|
{ href: "/ernten", key: "harvests" },
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getContentValues } from "@/lib/content";
|
||||||
|
|
||||||
|
const SOCIAL_KEYS = ["social.tiktok", "social.youtube", "social.instagram"] as const;
|
||||||
|
|
||||||
|
export interface SocialLinks {
|
||||||
|
tiktok: string;
|
||||||
|
youtube: string;
|
||||||
|
instagram: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-gepflegte Social-Media-Links (siehe admin/inhalte, Gruppe "Social
|
||||||
|
* Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer
|
||||||
|
* String bedeutet "kein Profil hinterlegt"; der Footer rendert dann kein
|
||||||
|
* Icon fuer diese Plattform (siehe components/site-footer.tsx Footer()).
|
||||||
|
*/
|
||||||
|
export async function getSocialLinks(): Promise<SocialLinks> {
|
||||||
|
const v = await getContentValues([...SOCIAL_KEYS], "de");
|
||||||
|
return {
|
||||||
|
tiktok: v["social.tiktok"],
|
||||||
|
youtube: v["social.youtube"],
|
||||||
|
instagram: v["social.instagram"],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user