Drei echte Luecken gefunden und geschlossen: 1. Die 27 Server Actions verliessen sich allein auf proxy.ts. Jede prueft die Sitzung jetzt selbst und schlaegt gegen die Datenbank nach, ob das Konto noch gilt - damit lassen sich Sitzungen ueberhaupt erst widerrufen. 2. Der Anmelde-Endpunkt prueft die Herkunft des Formulars; vorher war Login-CSRF moeglich. 3. Content-Security-Policy mit Nonce je Anfrage und strict-dynamic. Dazu: npm audit ist ohne Befund (mysql2 und deepmerge-ts per overrides angehoben statt Prisma zurueckzustufen), eine Benutzerverwaltung fuer die Kommandozeile und docs/sicherheit.md mit allem, was bewusst offen bleibt - keine Rollen, keine Zwei-Faktor-Anmeldung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
4.7 KiB
TypeScript
132 lines
4.7 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { z } from "zod";
|
|
import { prisma } from "@/lib/db";
|
|
import { protokolliere } from "@/server/audit";
|
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|
|
|
export interface AktionsErgebnis {
|
|
fehler?: string;
|
|
felder?: Record<string, string>;
|
|
erfolg?: string;
|
|
}
|
|
|
|
const leerZuNull = (wert: FormDataEntryValue | null) => {
|
|
const text = typeof wert === "string" ? wert.trim() : "";
|
|
return text.length > 0 ? text : null;
|
|
};
|
|
|
|
const kundeSchema = z.object({
|
|
name: z.string().min(2, "Bitte einen Namen angeben."),
|
|
kundennummer: z.string().min(1, "Bitte eine Kundennummer angeben."),
|
|
ansprechpartner: z.string().nullable(),
|
|
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
|
telefon: z.string().nullable(),
|
|
notizen: z.string().nullable(),
|
|
});
|
|
|
|
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
|
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
|
await sitzungErforderlich();
|
|
const id = leerZuNull(formular.get("id"));
|
|
const eingabe = kundeSchema.safeParse({
|
|
name: formular.get("name")?.toString().trim() ?? "",
|
|
kundennummer: formular.get("kundennummer")?.toString().trim() ?? "",
|
|
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
|
email: leerZuNull(formular.get("email")),
|
|
telefon: leerZuNull(formular.get("telefon")),
|
|
notizen: leerZuNull(formular.get("notizen")),
|
|
});
|
|
|
|
if (!eingabe.success) {
|
|
const felder: Record<string, string> = {};
|
|
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
|
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
|
}
|
|
|
|
const vorhandeneNummer = await prisma.kunde.findUnique({
|
|
where: { kundennummer: eingabe.data.kundennummer },
|
|
});
|
|
if (vorhandeneNummer && vorhandeneNummer.id !== id) {
|
|
return {
|
|
fehler: "Diese Kundennummer ist bereits vergeben.",
|
|
felder: { kundennummer: "Bereits vergeben." },
|
|
};
|
|
}
|
|
|
|
const kunde = id
|
|
? await prisma.kunde.update({ where: { id }, data: eingabe.data })
|
|
: await prisma.kunde.create({ data: eingabe.data });
|
|
|
|
await protokolliere(id ? "kunde.geaendert" : "kunde.angelegt", "Kunde", kunde.id, eingabe.data);
|
|
|
|
revalidatePath("/kunden");
|
|
revalidatePath(`/kunden/${kunde.id}`);
|
|
redirect(`/kunden/${kunde.id}`);
|
|
}
|
|
|
|
export async function kundeLoeschen(formular: FormData) {
|
|
await sitzungErforderlich();
|
|
const id = formular.get("id")?.toString();
|
|
if (!id) return;
|
|
|
|
const kunde = await prisma.kunde.findUnique({ where: { id }, select: { name: true } });
|
|
// Hosts bleiben erhalten und gelten danach als nicht zugeordnet (onDelete: SetNull).
|
|
await prisma.kunde.delete({ where: { id } });
|
|
await protokolliere("kunde.geloescht", "Kunde", id, { name: kunde?.name });
|
|
|
|
revalidatePath("/kunden");
|
|
revalidatePath("/hosts/nicht-zugeordnet");
|
|
redirect("/kunden");
|
|
}
|
|
|
|
const standortSchema = z.object({
|
|
kundeId: z.string().min(1),
|
|
name: z.string().min(2, "Bitte einen Namen angeben."),
|
|
strasse: z.string().nullable(),
|
|
plz: z.string().nullable(),
|
|
ort: z.string().nullable(),
|
|
});
|
|
|
|
export async function standortSpeichern(
|
|
_vorher: AktionsErgebnis,
|
|
formular: FormData,
|
|
): Promise<AktionsErgebnis> {
|
|
await sitzungErforderlich();
|
|
const id = leerZuNull(formular.get("id"));
|
|
const eingabe = standortSchema.safeParse({
|
|
kundeId: formular.get("kundeId")?.toString() ?? "",
|
|
name: formular.get("name")?.toString().trim() ?? "",
|
|
strasse: leerZuNull(formular.get("strasse")),
|
|
plz: leerZuNull(formular.get("plz")),
|
|
ort: leerZuNull(formular.get("ort")),
|
|
});
|
|
|
|
if (!eingabe.success) {
|
|
const felder: Record<string, string> = {};
|
|
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
|
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
|
}
|
|
|
|
const standort = id
|
|
? await prisma.standort.update({ where: { id }, data: eingabe.data })
|
|
: await prisma.standort.create({ data: eingabe.data });
|
|
|
|
await protokolliere(id ? "standort.geaendert" : "standort.angelegt", "Standort", standort.id, eingabe.data);
|
|
revalidatePath(`/kunden/${eingabe.data.kundeId}`);
|
|
return { erfolg: "Standort gespeichert." };
|
|
}
|
|
|
|
export async function standortLoeschen(formular: FormData) {
|
|
await sitzungErforderlich();
|
|
const id = formular.get("id")?.toString();
|
|
const kundeId = formular.get("kundeId")?.toString();
|
|
if (!id) return;
|
|
|
|
await prisma.standort.delete({ where: { id } });
|
|
await protokolliere("standort.geloescht", "Standort", id, {});
|
|
if (kundeId) revalidatePath(`/kunden/${kundeId}`);
|
|
}
|