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>
)
}