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:
co-authored by
Claude Opus 5
parent
cc013fd6a1
commit
e9a2b4b463
@@ -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.` }
|
||||
}
|
||||
@@ -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.` }
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user