fix: fehlende Dateien aus vorherigem Commit nachreichen
CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die Harvest-isPublished-Migration waren gestaged aber nicht im letzten Commit enthalten — Gitea-Actions-Build brach mit "Module not found" fuer genau diese Dateien ab. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
4d3100e9ba
commit
b6db9254a9
10 files changed
+1205
No files matched your search
@@ -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,104 @@
|
||||
"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";
|
||||
|
||||
/**
|
||||
* 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.");
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
Reference in new issue
Block a user