feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit

Bereiche mit Farbe, Arbeitsfenster und Tageskapazität – die erste
schreibende Funktion und damit der Beleg, dass die Kette trägt: zod prüft
jede Eingabe, die Rolle wird in der Seite und noch einmal in der Server
Action geprüft, jede Änderung hinterlässt einen Audit-Eintrag.

Die Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name
nach Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt.
Das gilt auch in verschachtelten Objekten und Listen.

Dazu die Protokollseite für die Verwaltung und die Startseite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 13:02:49 +02:00
co-authored by Claude Opus 5
parent cc013fd6a1
commit e9a2b4b463
14 changed files with 1432 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { bereichSchema } from '@/lib/bereich'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
/**
* Server Actions fuer Bereiche.
*
* Jede prueft die Rolle selbst – die Seite tut es ebenfalls, aber eine Action
* ist ein eigener Einstiegspunkt und darf sich darauf nicht verlassen. Jede
* schreibende Aktion hinterlaesst einen Audit-Eintrag.
*/
export type Rueckmeldung =
| { ok: true; meldung: string }
| { ok: false; fehler: string; felder?: Record<string, string> }
function feldFehler(fehler: z.ZodError): Record<string, string> {
const felder: Record<string, string> = {}
for (const problem of fehler.issues) {
const feld = problem.path.join('.')
felder[feld] ??= problem.message
}
return felder
}
function alsFormularwerte(daten: FormData): Record<string, unknown> {
const werte = Object.fromEntries(daten.entries())
return {
...werte,
// Ein nicht angehaktes Kaestchen taucht im FormData gar nicht auf.
aktiv: daten.get('aktiv') !== null,
}
}
export async function bereichAnlegen(daten: FormData): Promise<Rueckmeldung> {
let akteur
try {
akteur = await pruefeRolle('ADMIN')
} catch (fehler) {
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
throw fehler
}
const gelesen = bereichSchema.safeParse(alsFormularwerte(daten))
if (!gelesen.success) {
return {
ok: false,
fehler: 'Bitte die markierten Felder prüfen.',
felder: feldFehler(gelesen.error),
}
}
const vorhanden = await db.bereich.findUnique({
where: { name: gelesen.data.name },
select: { id: true },
})
if (vorhanden) {
return {
ok: false,
fehler: 'Diesen Namen gibt es schon.',
felder: { name: 'Diesen Namen gibt es schon.' },
}
}
const bereich = await db.bereich.create({
data: gelesen.data,
select: { id: true, name: true },
})
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'bereich.angelegt',
objektTyp: 'Bereich',
objektId: bereich.id,
details: { name: bereich.name, farbe: gelesen.data.farbe },
})
revalidatePath('/bereiche')
return { ok: true, meldung: `Bereich „${bereich.name}" angelegt.` }
}
export async function bereichAendern(id: string, daten: FormData): Promise<Rueckmeldung> {
let akteur
try {
akteur = await pruefeRolle('ADMIN')
} catch (fehler) {
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
throw fehler
}
const gelesen = bereichSchema.safeParse(alsFormularwerte(daten))
if (!gelesen.success) {
return {
ok: false,
fehler: 'Bitte die markierten Felder prüfen.',
felder: feldFehler(gelesen.error),
}
}
const vorher = await db.bereich.findUnique({
where: { id },
select: { id: true, name: true },
})
if (!vorher) return { ok: false, fehler: 'Diesen Bereich gibt es nicht mehr.' }
const namensdublette = await db.bereich.findFirst({
where: { name: gelesen.data.name, id: { not: id } },
select: { id: true },
})
if (namensdublette) {
return {
ok: false,
fehler: 'Diesen Namen gibt es schon.',
felder: { name: 'Diesen Namen gibt es schon.' },
}
}
await db.bereich.update({ where: { id }, data: gelesen.data })
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'bereich.geaendert',
objektTyp: 'Bereich',
objektId: id,
details: { vorher: vorher.name, nachher: gelesen.data.name },
})
revalidatePath('/bereiche')
return { ok: true, meldung: `Bereich „${gelesen.data.name}" gespeichert.` }
}
const loeschEingabe = z.object({ id: z.string().min(1) })
export async function bereichLoeschen(daten: FormData): Promise<Rueckmeldung> {
let akteur
try {
akteur = await pruefeRolle('ADMIN')
} catch (fehler) {
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
throw fehler
}
const gelesen = loeschEingabe.safeParse({ id: daten.get('id') })
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
const bereich = await db.bereich.findUnique({
where: { id: gelesen.data.id },
select: { id: true, name: true },
})
if (!bereich) return { ok: false, fehler: 'Diesen Bereich gibt es nicht mehr.' }
await db.bereich.delete({ where: { id: bereich.id } })
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'bereich.geloescht',
objektTyp: 'Bereich',
objektId: bereich.id,
details: { name: bereich.name },
})
revalidatePath('/bereiche')
return { ok: true, meldung: `Bereich „${bereich.name}" gelöscht.` }
}
+48
View File
@@ -0,0 +1,48 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string }
const eingabe = z.object({ id: z.string().min(1) })
/**
* Entfernt einen Passkey. Jeder darf nur seine eigenen entfernen – deshalb
* geht die Benutzer-Kennung in die where-Bedingung und wird nicht nur
* nachträglich verglichen.
*/
export async function passkeyEntfernen(daten: FormData): Promise<Rueckmeldung> {
let akteur
try {
akteur = await pruefeRolle('BENUTZER')
} catch (fehler) {
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
throw fehler
}
const gelesen = eingabe.safeParse({ id: daten.get('id') })
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
const passkey = await db.passkey.findFirst({
where: { id: gelesen.data.id, benutzerId: akteur.id },
select: { id: true, bezeichnung: true },
})
if (!passkey) return { ok: false, fehler: 'Diesen Passkey gibt es nicht mehr.' }
await db.passkey.delete({ where: { id: passkey.id } })
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'passkey.entfernt',
objektTyp: 'Passkey',
objektId: passkey.id,
details: { bezeichnung: passkey.bezeichnung },
})
revalidatePath('/einstellungen')
return { ok: true, meldung: `Passkey „${passkey.bezeichnung}" entfernt.` }
}
+44
View File
@@ -0,0 +1,44 @@
import 'server-only'
import type { Prisma } from '@/generated/prisma/client'
import { auditDetails } from '@/lib/audit'
import { db } from '@/server/db'
import { aktuelleInstanz } from '@/server/instanz'
/**
* Nachweis jeder schreibenden Aktion. Die Details werden vorher gesaeubert
* (siehe @/lib/audit), damit keine Geheimnisse in der Datenbank landen.
*/
export type AuditAkteur = {
id: string | null
email: string
}
export type AuditEingabe = {
akteur: AuditAkteur
/** Was geschah, in der Form "objekt.verb", z. B. "bereich.angelegt". */
aktion: string
objektTyp: string
objektId?: string | null
details?: Record<string, unknown> | undefined
}
/**
* Schreibt einen Audit-Eintrag. Schlaegt das fehl, darf die eigentliche
* Aktion nicht als geglueckt gelten – deshalb wird der Fehler weitergereicht
* und nicht verschluckt.
*/
export async function auditSchreiben(eingabe: AuditEingabe): Promise<void> {
const details = auditDetails(eingabe.details)
await db.auditEintrag.create({
data: {
benutzerId: eingabe.akteur.id,
benutzerEmail: eingabe.akteur.email,
aktion: eingabe.aktion,
objektTyp: eingabe.objektTyp,
objektId: eingabe.objektId ?? null,
...(details ? { details: details as Prisma.InputJsonObject } : {}),
instanz: aktuelleInstanz(),
},
})
}