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
+235
View File
@@ -0,0 +1,235 @@
'use client'
import { useState, useTransition } from 'react'
import { Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { KAPAZITAET_FELDER, WOCHENTAGE } from '@/lib/bereich'
import {
bereichAendern,
bereichAnlegen,
bereichLoeschen,
type Rueckmeldung,
} from '@/server/aktionen/bereiche'
type Werte = {
id: string
name: string
farbe: string
reihenfolge: number
aktiv: boolean
arbeitsfensterVon: string
arbeitsfensterBis: string
} & Record<(typeof KAPAZITAET_FELDER)[number], number>
const LEER = {
name: '',
farbe: '#2563eb',
reihenfolge: 0,
aktiv: true,
arbeitsfensterVon: '08:00',
arbeitsfensterBis: '17:00',
kapazitaetMo: 0,
kapazitaetDi: 0,
kapazitaetMi: 0,
kapazitaetDo: 0,
kapazitaetFr: 0,
kapazitaetSa: 0,
kapazitaetSo: 0,
} satisfies Omit<Werte, 'id'>
export function BereichFormular({ bereich }: { bereich?: Werte }) {
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [offen, setOffen] = useState(!bereich)
const werte = bereich ?? LEER
function verarbeite(antwort: Rueckmeldung, formular: HTMLFormElement) {
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
if (!bereich) formular.reset()
return
}
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault()
const formular = ereignis.currentTarget
const daten = new FormData(formular)
starte(async () => {
const antwort = bereich
? await bereichAendern(bereich.id, daten)
: await bereichAnlegen(daten)
verarbeite(antwort, formular)
})
}
function loeschen() {
if (!bereich) return
const daten = new FormData()
daten.set('id', bereich.id)
starte(async () => {
const antwort = await bereichLoeschen(daten)
if (antwort.ok) toast.success(antwort.meldung)
else toast.error(antwort.fehler)
})
}
if (bereich && !offen) {
return (
<Button art="umriss" groesse="klein" onClick={() => setOffen(true)}>
Bearbeiten
</Button>
)
}
return (
<form onSubmit={absenden} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={`name-${werte.name}`}>Name</Label>
<Input
id={`name-${werte.name}`}
name="name"
defaultValue={werte.name}
required
aria-invalid={Boolean(felder.name)}
aria-describedby={felder.name ? `name-fehler-${werte.name}` : undefined}
/>
{felder.name && (
<p id={`name-fehler-${werte.name}`} className="text-gefahr text-xs">
{felder.name}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`farbe-${werte.name}`}>Farbe</Label>
<Input
id={`farbe-${werte.name}`}
name="farbe"
type="color"
defaultValue={werte.farbe}
className="h-9 w-20 p-1"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`von-${werte.name}`}>Arbeitsfenster von</Label>
<Input
id={`von-${werte.name}`}
name="arbeitsfensterVon"
type="time"
defaultValue={werte.arbeitsfensterVon}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`bis-${werte.name}`}>bis</Label>
<Input
id={`bis-${werte.name}`}
name="arbeitsfensterBis"
type="time"
defaultValue={werte.arbeitsfensterBis}
required
aria-invalid={Boolean(felder.arbeitsfensterBis)}
/>
{felder.arbeitsfensterBis && (
<p className="text-gefahr text-xs">{felder.arbeitsfensterBis}</p>
)}
</div>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">Tageskapazität in Minuten</legend>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
{KAPAZITAET_FELDER.map((feld, i) => (
<div key={feld} className="flex flex-col gap-1">
<Label htmlFor={`${feld}-${werte.name}`} className="text-xs font-normal">
{WOCHENTAGE[i]}
</Label>
<Input
id={`${feld}-${werte.name}`}
name={feld}
type="number"
min={0}
max={1440}
step={15}
defaultValue={werte[feld]}
/>
</div>
))}
</div>
{felder.kapazitaetMo && (
<p className="text-gefahr text-xs">{felder.kapazitaetMo}</p>
)}
</fieldset>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<input
id={`aktiv-${werte.name}`}
name="aktiv"
type="checkbox"
defaultChecked={werte.aktiv}
className="accent-betont size-4"
/>
<Label htmlFor={`aktiv-${werte.name}`} className="font-normal">
In Benutzung
</Label>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`reihenfolge-${werte.name}`} className="font-normal">
Reihenfolge
</Label>
<Input
id={`reihenfolge-${werte.name}`}
name="reihenfolge"
type="number"
min={0}
max={999}
defaultValue={werte.reihenfolge}
className="w-20"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
{bereich ? 'Speichern' : 'Bereich anlegen'}
</Button>
{bereich && (
<>
<Button
type="button"
art="leise"
onClick={() => setOffen(false)}
disabled={laeuft}
>
Schließen
</Button>
<Button
type="button"
art="gefahr"
onClick={loeschen}
disabled={laeuft}
className="ml-auto"
>
<Trash2 aria-hidden />
Löschen
</Button>
</>
)}
</div>
</form>
)
}
+124
View File
@@ -0,0 +1,124 @@
import type { Metadata } from 'next'
import { BereichFormular } from '@/app/(app)/bereiche/bereich-formular'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { KAPAZITAET_FELDER, WOCHENTAGE, wochenKapazitaet } from '@/lib/bereich'
import { istAdmin } from '@/lib/rollen'
import { alsDauer, alsUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
export const metadata: Metadata = { title: 'Bereiche' }
export default async function BereicheSeite() {
const benutzer = await verlangeAnmeldung('/bereiche')
const darfAendern = istAdmin(benutzer.rolle)
const bereiche = await db.bereich.findMany({
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
})
return (
<div className="flex flex-col gap-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Bereiche</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Farbe, Arbeitsfenster und Tageskapazität je Lebensbereich. Die Kapazität
begrenzt später, wie viel an einem Tag eingeplant werden kann.
</p>
</header>
{bereiche.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch keine Bereiche angelegt.
</CardInhalt>
</Card>
) : (
<ul className="flex flex-col gap-4">
{bereiche.map((bereich) => (
<li key={bereich.id}>
<Card>
<CardKopf className="flex-row items-center gap-3">
<span
aria-hidden
className="size-4 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: bereich.farbe }}
/>
<CardTitel className="flex-1">{bereich.name}</CardTitel>
{!bereich.aktiv && (
<span className="bg-gedaempft text-gedaempft-vordergrund rounded-md px-2 py-0.5 text-xs">
stillgelegt
</span>
)}
</CardKopf>
<CardInhalt className="flex flex-col gap-4">
<dl className="text-sm">
<div className="flex gap-2">
<dt className="text-gedaempft-vordergrund">Arbeitsfenster:</dt>
<dd>
{alsUhrzeit(bereich.arbeitsfensterVon)} bis{' '}
{alsUhrzeit(bereich.arbeitsfensterBis)} Uhr
</dd>
</div>
<div className="flex gap-2">
<dt className="text-gedaempft-vordergrund">Woche gesamt:</dt>
<dd>{alsDauer(wochenKapazitaet(bereich))}</dd>
</div>
</dl>
<ul className="flex flex-wrap gap-2">
{KAPAZITAET_FELDER.map((feld, i) => (
<li
key={feld}
className="bg-gedaempft rounded-md px-2 py-1 text-xs"
title={WOCHENTAGE[i]}
>
<span className="text-gedaempft-vordergrund">
{WOCHENTAGE[i]?.slice(0, 2)}
</span>{' '}
{alsDauer(bereich[feld])}
</li>
))}
</ul>
{darfAendern && (
<BereichFormular
bereich={{
id: bereich.id,
name: bereich.name,
farbe: bereich.farbe,
reihenfolge: bereich.reihenfolge,
aktiv: bereich.aktiv,
arbeitsfensterVon: alsUhrzeit(bereich.arbeitsfensterVon),
arbeitsfensterBis: alsUhrzeit(bereich.arbeitsfensterBis),
kapazitaetMo: bereich.kapazitaetMo,
kapazitaetDi: bereich.kapazitaetDi,
kapazitaetMi: bereich.kapazitaetMi,
kapazitaetDo: bereich.kapazitaetDo,
kapazitaetFr: bereich.kapazitaetFr,
kapazitaetSa: bereich.kapazitaetSa,
kapazitaetSo: bereich.kapazitaetSo,
}}
/>
)}
</CardInhalt>
</Card>
</li>
))}
</ul>
)}
{darfAendern && (
<Card>
<CardKopf>
<CardTitel>Neuen Bereich anlegen</CardTitel>
</CardKopf>
<CardInhalt>
<BereichFormular />
</CardInhalt>
</Card>
)}
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from 'next'
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import { ROLLEN_NAME } from '@/lib/rollen'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
export const metadata: Metadata = { title: 'Einstellungen' }
export default async function EinstellungenSeite() {
const benutzer = await verlangeAnmeldung('/einstellungen')
const passkeys = await db.passkey.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
select: {
id: true,
bezeichnung: true,
erstelltAm: true,
zuletztBenutzt: true,
gesichert: true,
},
})
return (
<div className="flex flex-col gap-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Einstellungen</h1>
</header>
<Card>
<CardKopf>
<CardTitel className="text-base">Konto</CardTitel>
</CardKopf>
<CardInhalt>
<dl className="grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
<dt className="text-gedaempft-vordergrund">Name</dt>
<dd>{benutzer.name ?? '—'}</dd>
<dt className="text-gedaempft-vordergrund">E-Mail</dt>
<dd>{benutzer.email}</dd>
<dt className="text-gedaempft-vordergrund">Rolle</dt>
<dd>{ROLLEN_NAME[benutzer.rolle]}</dd>
</dl>
</CardInhalt>
</Card>
<Card>
<CardKopf>
<CardTitel className="text-base">Passkeys</CardTitel>
<CardBeschreibung>
Mit einem Passkey meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN
an – ohne Passwort. Hinterlege am besten zwei, damit du bei Verlust eines
Geräts nicht ausgesperrt bist.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<PasskeyVerwaltung
passkeys={passkeys.map((p) => ({
id: p.id,
bezeichnung: p.bezeichnung,
erstelltAm: alsDatumUhrzeit(p.erstelltAm),
zuletztBenutzt: p.zuletztBenutzt ? alsDatumUhrzeit(p.zuletztBenutzt) : null,
gesichert: p.gesichert,
}))}
/>
</CardInhalt>
</Card>
</div>
)
}
@@ -0,0 +1,153 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { startRegistration } from '@simplewebauthn/browser'
import { Fingerprint, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { passkeyEntfernen } from '@/server/aktionen/passkeys'
export type PasskeyAnzeige = {
id: string
bezeichnung: string
erstelltAm: string
zuletztBenutzt: string | null
gesichert: boolean
}
export function PasskeyVerwaltung({ passkeys }: { passkeys: PasskeyAnzeige[] }) {
const router = useRouter()
const [bezeichnung, setBezeichnung] = useState('')
const [legtAn, setLegtAn] = useState(false)
const [laeuft, starte] = useTransition()
async function anlegen(ereignis: React.FormEvent) {
ereignis.preventDefault()
const name = bezeichnung.trim()
if (!name) {
toast.error('Bitte einen Namen für das Gerät angeben.')
return
}
setLegtAn(true)
try {
const antwortOptionen = await fetch('/api/passkey/registrierung')
if (!antwortOptionen.ok) {
toast.error('Die Anfrage wurde abgelehnt.')
return
}
const optionen = (await antwortOptionen.json()) as Parameters<
typeof startRegistration
>[0]['optionsJSON']
const antwort = await startRegistration({ optionsJSON: optionen })
const gespeichert = await fetch('/api/passkey/registrierung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bezeichnung: name, antwort }),
})
const ergebnis = (await gespeichert.json()) as { fehler?: string }
if (!gespeichert.ok) {
toast.error(ergebnis.fehler ?? 'Der Passkey konnte nicht gespeichert werden.')
return
}
toast.success(`Passkey „${name}" angelegt.`)
setBezeichnung('')
router.refresh()
} catch (fehler) {
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
if (fehler instanceof Error && fehler.name === 'NotAllowedError') return
if (fehler instanceof Error && fehler.name === 'InvalidStateError') {
toast.error('Dieses Gerät ist hier schon hinterlegt.')
return
}
toast.error('Der Passkey konnte nicht angelegt werden.')
} finally {
setLegtAn(false)
}
}
function entfernen(id: string) {
const daten = new FormData()
daten.set('id', id)
starte(async () => {
const antwort = await passkeyEntfernen(daten)
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
return (
<div className="flex flex-col gap-6">
{passkeys.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
Noch kein Passkey hinterlegt.
</p>
) : (
<ul className="divide-rand divide-y">
{passkeys.map((passkey) => (
<li
key={passkey.id}
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-3"
>
<Fingerprint className="text-gedaempft-vordergrund size-4" aria-hidden />
<div className="flex-1">
<p className="text-sm font-medium">{passkey.bezeichnung}</p>
<p className="text-gedaempft-vordergrund text-xs">
Angelegt {passkey.erstelltAm}
{passkey.zuletztBenutzt
? ` · zuletzt benutzt ${passkey.zuletztBenutzt}`
: ' · noch nicht benutzt'}
{passkey.gesichert ? ' · geräteübergreifend' : ' · nur dieses Gerät'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={`Passkey ${passkey.bezeichnung} entfernen`}
onClick={() => entfernen(passkey.id)}
disabled={laeuft}
>
<Trash2 aria-hidden />
<span className="sr-only">Entfernen</span>
</Button>
</li>
))}
</ul>
)}
<form onSubmit={(e) => void anlegen(e)} className="flex flex-wrap items-end gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="passkey-name">Neuen Passkey anlegen</Label>
<Input
id="passkey-name"
value={bezeichnung}
onChange={(e) => setBezeichnung(e.target.value)}
placeholder="z. B. Diensthandy"
maxLength={60}
disabled={legtAn}
/>
</div>
<Button type="submit" art="umriss" disabled={legtAn}>
{legtAn ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<Fingerprint aria-hidden />
)}
Anlegen
</Button>
</form>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Kopfleiste } from '@/components/kopfleiste'
import { INSTANZ_NAME } from '@/lib/instanz'
import { versionAnzeige } from '@/lib/version'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { aktuelleInstanz } from '@/server/instanz'
/**
* Alles unterhalb von (app) setzt eine Anmeldung voraus. Der Riegel sitzt
* hier im Layout und zusaetzlich in jeder Server Action – nicht nur in der
* Oberflaeche.
*/
export default async function AngemeldetesLayout({
children,
}: {
children: React.ReactNode
}) {
const benutzer = await verlangeAnmeldung()
const instanz = aktuelleInstanz()
return (
<div className="flex min-h-dvh flex-col">
<Kopfleiste
name={benutzer.name ?? benutzer.email}
email={benutzer.email}
rolle={benutzer.rolle}
instanzName={INSTANZ_NAME[instanz]}
/>
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
<footer className="text-gedaempft-vordergrund border-rand border-t px-4 py-4 text-center text-xs">
Planer {versionAnzeige()} · {INSTANZ_NAME[instanz]}
</footer>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import Link from 'next/link'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import { wochenKapazitaet } from '@/lib/bereich'
import { alsDatum, alsDauer } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/')
const jetzt = new Date()
const bereiche = await db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
})
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
return (
<div className="flex flex-col gap-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {anrede}</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(jetzt)}</p>
</header>
<section className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Deine Bereiche</h2>
{bereiche.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
Noch keine Bereiche angelegt.{' '}
<Link href="/bereiche" className="text-betont underline underline-offset-4">
Jetzt einrichten
</Link>
</CardInhalt>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{bereiche.map((bereich) => (
<li key={bereich.id}>
<Card>
<CardKopf className="flex-row items-center gap-3">
<span
aria-hidden
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: bereich.farbe }}
/>
<CardTitel className="text-base">{bereich.name}</CardTitel>
</CardKopf>
<CardInhalt>
<p className="text-gedaempft-vordergrund text-sm">
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
</p>
</CardInhalt>
</Card>
</li>
))}
</ul>
)}
</section>
<Card>
<CardKopf>
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
<CardBeschreibung>
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
Wochenplanung per Ziehen und Ablegen mit M4.
</CardBeschreibung>
</CardKopf>
</Card>
</div>
)
}
@@ -0,0 +1,91 @@
import type { Metadata } from 'next'
import { Card, CardInhalt } from '@/components/ui/card'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
export const metadata: Metadata = { title: 'Protokoll' }
const SEITENGROESSE = 100
export default async function ProtokollSeite() {
// Zweiter Riegel: das Layout prueft die Anmeldung, hier zusaetzlich die Rolle.
await verlangeRolle('ADMIN', '/verwaltung/protokoll')
const eintraege = await db.auditEintrag.findMany({
orderBy: { zeitpunkt: 'desc' },
take: SEITENGROESSE,
select: {
id: true,
zeitpunkt: true,
benutzerEmail: true,
aktion: true,
objektTyp: true,
objektId: true,
details: true,
instanz: true,
},
})
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Protokoll</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Die letzten {SEITENGROESSE} schreibenden Aktionen. Einträge lassen sich nicht
ändern und nicht löschen.
</p>
</header>
{eintraege.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch nichts protokolliert.
</CardInhalt>
</Card>
) : (
<Card>
<CardInhalt className="overflow-x-auto p-0">
<table className="w-full text-left text-sm">
<thead className="text-gedaempft-vordergrund border-rand border-b">
<tr>
<th scope="col" className="px-4 py-3 font-medium">
Zeitpunkt
</th>
<th scope="col" className="px-4 py-3 font-medium">
Wer
</th>
<th scope="col" className="px-4 py-3 font-medium">
Was
</th>
<th scope="col" className="px-4 py-3 font-medium">
Objekt
</th>
</tr>
</thead>
<tbody className="divide-rand divide-y">
{eintraege.map((eintrag) => (
<tr key={eintrag.id}>
<td className="text-gedaempft-vordergrund px-4 py-2 whitespace-nowrap">
{alsDatumUhrzeit(eintrag.zeitpunkt)}
</td>
<td className="px-4 py-2">{eintrag.benutzerEmail}</td>
<td className="px-4 py-2 font-medium">{eintrag.aktion}</td>
<td className="text-gedaempft-vordergrund px-4 py-2">
{eintrag.objektTyp}
{eintrag.details ? (
<span className="ml-2 text-xs">
{JSON.stringify(eintrag.details)}
</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</CardInhalt>
</Card>
)}
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { ENTFERNT, auditDetails, detailsSaeubern } from './audit'
describe('auditDetails', () => {
it('laesst fachliche Felder unangetastet', () => {
expect(auditDetails({ name: 'Gewerbe', farbe: '#2563eb', reihenfolge: 2 })).toEqual({
name: 'Gewerbe',
farbe: '#2563eb',
reihenfolge: 2,
})
})
it('gibt fuer nichts auch nichts zurueck', () => {
expect(auditDetails(undefined)).toBeUndefined()
})
it('entfernt Passwoerter und Tokens', () => {
expect(
auditDetails({
email: 'a@b.de',
passwort: 'geheim123',
passwortHash: '$2b$12$...',
access_token: 'ey...',
}),
).toEqual({
email: 'a@b.de',
passwort: ENTFERNT,
passwortHash: ENTFERNT,
access_token: ENTFERNT,
})
})
it('entfernt Termin-Betreffs und Mailinhalte', () => {
expect(
auditDetails({ betreff: 'Personalgespräch', body: 'Text', dauer: 30 }),
).toEqual({ betreff: ENTFERNT, body: ENTFERNT, dauer: 30 })
})
it('achtet nicht auf Gross- und Kleinschreibung', () => {
expect(auditDetails({ ClientSecret: 'x', REFRESH_TOKEN: 'y' })).toEqual({
ClientSecret: ENTFERNT,
REFRESH_TOKEN: ENTFERNT,
})
})
})
describe('detailsSaeubern', () => {
it('greift auch in verschachtelten Objekten', () => {
expect(detailsSaeubern({ konto: { anbieter: 'GRAPH', id_token: 'ey...' } })).toEqual({
konto: { anbieter: 'GRAPH', id_token: ENTFERNT },
})
})
it('greift in Listen', () => {
expect(detailsSaeubern([{ name: 'a', token: 'x' }])).toEqual([
{ name: 'a', token: ENTFERNT },
])
})
it('schreibt Datumsangaben als ISO-Zeichenkette', () => {
expect(detailsSaeubern({ am: new Date('2026-09-10T12:00:00Z') })).toEqual({
am: '2026-09-10T12:00:00.000Z',
})
})
it('schneidet zu tiefe Verschachtelung ab, statt endlos abzusteigen', () => {
expect(detailsSaeubern({ a: { b: { c: { d: { e: { f: 1 } } } } } })).toEqual({
a: { b: { c: { d: { e: ENTFERNT } } } },
})
})
it('kommt mit einfachen Werten und null zurecht', () => {
expect(detailsSaeubern('text')).toBe('text')
expect(detailsSaeubern(42)).toBe(42)
expect(detailsSaeubern(null)).toBeNull()
})
})
+66
View File
@@ -0,0 +1,66 @@
/**
* Aufbereitung der Audit-Details. Reine Logik, damit sie ohne Datenbank
* geprueft werden kann.
*
* In die Details gehoeren nur fachliche Felder – niemals Tokens, Passwoerter,
* Mailinhalte oder Termin-Betreffs. Was hier landet, steht spaeter im
* Klartext in der Datenbank und in jeder Sicherung.
*/
/** Feldnamen, die auch versehentlich nie in einem Audit-Eintrag landen duerfen. */
const VERBOTEN = [
'passwort',
'password',
'token',
'secret',
'geheim',
'clientsecret',
'betreff',
'subject',
'inhalt',
'body',
'nachricht',
]
export const ENTFERNT = '[entfernt]'
function istVerdaechtig(schluessel: string): boolean {
const klein = schluessel.toLowerCase()
return VERBOTEN.some((v) => klein.includes(v))
}
/**
* Zweiter Riegel gegen Unachtsamkeit: Felder mit verdaechtigem Namen werden
* ersetzt, statt gespeichert zu werden. Wirkt auch in verschachtelten
* Objekten und in Listen.
*/
export function detailsSaeubern(wert: unknown, tiefe = 0): unknown {
// Tief verschachtelte Strukturen gehoeren nicht in ein Audit-Detail; ab
// einer gewissen Tiefe wird abgeschnitten statt endlos abgestiegen.
if (tiefe > 4) return ENTFERNT
if (Array.isArray(wert)) {
return wert.map((eintrag) => detailsSaeubern(eintrag, tiefe + 1))
}
if (wert !== null && typeof wert === 'object') {
if (wert instanceof Date) return wert.toISOString()
const sauber: Record<string, unknown> = {}
for (const [schluessel, inhalt] of Object.entries(wert)) {
sauber[schluessel] = istVerdaechtig(schluessel)
? ENTFERNT
: detailsSaeubern(inhalt, tiefe + 1)
}
return sauber
}
return wert
}
/** Bequemer Einstieg fuer den Normalfall: ein flaches Objekt. */
export function auditDetails(
details: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!details) return undefined
return detailsSaeubern(details) as Record<string, unknown>
}
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest'
import {
KAPAZITAET_FELDER,
bereichSchema,
farbeSchema,
kapazitaetAmTag,
wochenKapazitaet,
} from './bereich'
const gueltig = {
name: 'Gewerbe',
farbe: '#2563EB',
reihenfolge: 1,
aktiv: true,
arbeitsfensterVon: '08:00',
arbeitsfensterBis: '17:00',
kapazitaetMo: 240,
kapazitaetDi: 240,
kapazitaetMi: 240,
kapazitaetDo: 240,
kapazitaetFr: 120,
kapazitaetSa: 0,
kapazitaetSo: 0,
}
describe('farbeSchema', () => {
it('nimmt sechsstellige und dreistellige Hex-Farben', () => {
expect(farbeSchema.parse('#2563eb')).toBe('#2563eb')
expect(farbeSchema.parse('#abc')).toBe('#abc')
})
it('macht Grossbuchstaben klein, damit Vergleiche stimmen', () => {
expect(farbeSchema.parse('#2563EB')).toBe('#2563eb')
})
it('weist Farben ohne Raute und mit falscher Laenge ab', () => {
expect(farbeSchema.safeParse('2563eb').success).toBe(false)
expect(farbeSchema.safeParse('#12345').success).toBe(false)
expect(farbeSchema.safeParse('blau').success).toBe(false)
})
})
describe('bereichSchema', () => {
it('nimmt einen vollstaendigen Bereich an und rechnet Uhrzeiten in Minuten um', () => {
const ergebnis = bereichSchema.parse(gueltig)
expect(ergebnis.arbeitsfensterVon).toBe(480)
expect(ergebnis.arbeitsfensterBis).toBe(1020)
expect(ergebnis.farbe).toBe('#2563eb')
})
it('verlangt einen Namen mit mindestens zwei Zeichen', () => {
const ergebnis = bereichSchema.safeParse({ ...gueltig, name: 'A' })
expect(ergebnis.success).toBe(false)
})
it('weist ein Arbeitsfenster ab, das vor seinem Beginn endet', () => {
const ergebnis = bereichSchema.safeParse({
...gueltig,
arbeitsfensterVon: '17:00',
arbeitsfensterBis: '08:00',
})
expect(ergebnis.success).toBe(false)
if (!ergebnis.success) {
expect(ergebnis.error.issues[0]?.message).toMatch(/nach seinem Beginn enden/)
}
})
it('weist ein Arbeitsfenster ohne Dauer ab', () => {
const ergebnis = bereichSchema.safeParse({
...gueltig,
arbeitsfensterVon: '08:00',
arbeitsfensterBis: '08:00',
})
expect(ergebnis.success).toBe(false)
})
it('weist eine Kapazitaet ab, die laenger ist als das Arbeitsfenster', () => {
// Fenster 08:00–17:00 sind 540 Minuten.
const ergebnis = bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 600 })
expect(ergebnis.success).toBe(false)
if (!ergebnis.success) {
expect(ergebnis.error.issues[0]?.message).toMatch(/Arbeitsfenster/)
}
})
it('laesst eine Kapazitaet genau in Fensterlaenge zu', () => {
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 540 }).success).toBe(true)
})
it('weist negative und uebergrosse Kapazitaeten ab', () => {
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: -1 }).success).toBe(false)
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 1441 }).success).toBe(
false,
)
})
it('weist eine unsinnige Uhrzeit ab', () => {
expect(
bereichSchema.safeParse({ ...gueltig, arbeitsfensterVon: '25:00' }).success,
).toBe(false)
})
})
describe('wochenKapazitaet', () => {
it('summiert alle sieben Tage', () => {
const bereich = bereichSchema.parse(gueltig)
expect(wochenKapazitaet(bereich)).toBe(1080)
})
it('ist null, wenn nichts eingeplant ist', () => {
const leer = Object.fromEntries(KAPAZITAET_FELDER.map((f) => [f, 0])) as Record<
(typeof KAPAZITAET_FELDER)[number],
number
>
expect(wochenKapazitaet(leer)).toBe(0)
})
})
describe('kapazitaetAmTag', () => {
it('trifft den richtigen Wochentag, Montag ist null', () => {
const bereich = bereichSchema.parse(gueltig)
expect(kapazitaetAmTag(bereich, 0)).toBe(240)
expect(kapazitaetAmTag(bereich, 4)).toBe(120)
expect(kapazitaetAmTag(bereich, 6)).toBe(0)
})
it('gibt fuer einen Index ausserhalb der Woche null zurueck', () => {
const bereich = bereichSchema.parse(gueltig)
expect(kapazitaetAmTag(bereich, 7)).toBe(0)
expect(kapazitaetAmTag(bereich, -1)).toBe(0)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { z } from 'zod'
import { ausUhrzeit } from '@/lib/zeit'
/**
* Regeln fuer Bereiche. Reine Logik, damit Formular und Server Action
* dieselbe Antwort geben.
*/
export const WOCHENTAGE = [
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
'Sonntag',
] as const
export const KAPAZITAET_FELDER = [
'kapazitaetMo',
'kapazitaetDi',
'kapazitaetMi',
'kapazitaetDo',
'kapazitaetFr',
'kapazitaetSa',
'kapazitaetSo',
] as const
export type KapazitaetFeld = (typeof KAPAZITAET_FELDER)[number]
/** Hex-Farbe mit Raute, drei- oder sechsstellig. */
export const farbeSchema = z
.string()
.trim()
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Bitte eine Farbe wie #2563eb angeben.')
.transform((f) => f.toLowerCase())
const uhrzeitSchema = z
.string()
.trim()
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 08:00 angeben.')
.transform((w) => ausUhrzeit(w) as number)
/** Eine Tageskapazitaet: 0 bis 24 Stunden, in Minuten. */
const kapazitaetSchema = z.coerce
.number()
.int('Bitte ganze Minuten angeben.')
.min(0, 'Nicht weniger als 0 Minuten.')
.max(1440, 'Nicht mehr als 1440 Minuten, also 24 Stunden.')
export const bereichSchema = z
.object({
name: z
.string()
.trim()
.min(2, 'Mindestens zwei Zeichen.')
.max(40, 'Höchstens 40 Zeichen.'),
farbe: farbeSchema,
reihenfolge: z.coerce.number().int().min(0).max(999).default(0),
aktiv: z.coerce.boolean().default(true),
arbeitsfensterVon: uhrzeitSchema,
arbeitsfensterBis: uhrzeitSchema,
kapazitaetMo: kapazitaetSchema,
kapazitaetDi: kapazitaetSchema,
kapazitaetMi: kapazitaetSchema,
kapazitaetDo: kapazitaetSchema,
kapazitaetFr: kapazitaetSchema,
kapazitaetSa: kapazitaetSchema,
kapazitaetSo: kapazitaetSchema,
})
.refine((b) => b.arbeitsfensterVon < b.arbeitsfensterBis, {
path: ['arbeitsfensterBis'],
message: 'Das Arbeitsfenster muss nach seinem Beginn enden.',
})
.refine(
(b) =>
KAPAZITAET_FELDER.every(
(feld) => b[feld] <= b.arbeitsfensterBis - b.arbeitsfensterVon,
),
{
path: ['kapazitaetMo'],
message: 'Keine Tageskapazität darf länger sein als das Arbeitsfenster.',
},
)
export type BereichEingabe = z.infer<typeof bereichSchema>
/** Summe der Wochenkapazitaet in Minuten. */
export function wochenKapazitaet(bereich: Record<KapazitaetFeld, number>): number {
return KAPAZITAET_FELDER.reduce((summe, feld) => summe + bereich[feld], 0)
}
/** Kapazitaet des Wochentags, 0 = Montag. */
export function kapazitaetAmTag(
bereich: Record<KapazitaetFeld, number>,
wochentagIndex: number,
): number {
const feld = KAPAZITAET_FELDER[wochentagIndex]
return feld ? bereich[feld] : 0
}
+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(),
},
})
}