Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter "Mein Konto". Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets und dessen Entsperrung. Damit das keine Annahme bleibt, wird die Nutzerverifikation angefordert (userVerification: 'required') und serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen. TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet; waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte schliessen. Aufbau: - Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey", nicht in der Server Action davor. Ein "war schon geprueft" ueber die Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen lassen. - Challenges liegen serverseitig und werden per DELETE genau einmal verbraucht - die Datenbank entscheidet, wer sie bekommt. - Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer zweiten Konfigurationsquelle. - Ohne https wird der Knopf gar nicht erst angeboten. - Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die Anmeldung nennt den verwendeten Passkey namentlich. Zwei Anmerkungen zur Migration: - "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe Suche stillgelegt, ohne dass etwas fehlgeschlagen waere. - "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine geloeschte. Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen, Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests, null Typfehler, Produktionsbau steht. Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46 statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man sie nachzaehlt.
226 lines
8.7 KiB
TypeScript
226 lines
8.7 KiB
TypeScript
import type { Metadata } from 'next'
|
|
import { ShieldCheck } from 'lucide-react'
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
|
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
|
import { db } from '@/lib/db'
|
|
import { entschluesselnWennMoeglich } from '@/lib/crypto/feld'
|
|
import { formatDatumZeit } from '@/lib/utils'
|
|
import type { AuditAktion } from '@prisma/client'
|
|
|
|
export const metadata: Metadata = { title: 'Protokoll' }
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
|
|
ERSTELLT: 'angelegt',
|
|
GEAENDERT: 'geändert',
|
|
GELOESCHT: 'in den Papierkorb',
|
|
WIEDERHERGESTELLT: 'wiederhergestellt',
|
|
ENDGUELTIG_GELOESCHT: 'endgültig gelöscht',
|
|
ANGESEHEN: 'angesehen',
|
|
EXPORTIERT: 'exportiert',
|
|
DOKUMENT_HERUNTERGELADEN: 'Dokument geöffnet',
|
|
ANMELDUNG: 'angemeldet',
|
|
ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen',
|
|
ABMELDUNG: 'abgemeldet',
|
|
ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet',
|
|
PASSKEY_REGISTRIERT: 'Passkey eingerichtet',
|
|
PASSKEY_ENTFERNT: 'Passkey entfernt',
|
|
PASSWORT_GEAENDERT: 'Passwort geändert',
|
|
ROLLE_GEAENDERT: 'Rolle geändert',
|
|
FREIGABE_GEAENDERT: 'Freigabe geändert',
|
|
}
|
|
|
|
function variante(aktion: AuditAktion) {
|
|
if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot'
|
|
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT' || aktion === 'PASSKEY_ENTFERNT')
|
|
return 'orange'
|
|
if (aktion === 'ERSTELLT') return 'gruen'
|
|
return 'secondary'
|
|
}
|
|
|
|
type Aenderung = Record<string, { alt: unknown; neu: unknown }>
|
|
|
|
function alsText(wert: unknown): string {
|
|
if (wert === null || wert === undefined || wert === '') return '—'
|
|
if (typeof wert === 'boolean') return wert ? 'ja' : 'nein'
|
|
if (Array.isArray(wert)) return wert.length ? wert.join(', ') : '—'
|
|
return String(wert)
|
|
}
|
|
|
|
export default async function ProtokollSeite({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ seite?: string }>
|
|
}) {
|
|
const benutzer = await erfordereBenutzer()
|
|
const aktive = await aktiveBetreute(benutzer)
|
|
if (!aktive) return null
|
|
|
|
await erfordereZugang(aktive.id, 'STAMMDATEN')
|
|
|
|
const { seite = '1' } = await searchParams
|
|
const proSeite = 50
|
|
const nummer = Math.max(1, Number.parseInt(seite, 10) || 1)
|
|
|
|
/*
|
|
* Zwei Arten von Eintraegen gehoeren hierher:
|
|
*
|
|
* 1. Alles, was diese Betreuung betrifft.
|
|
* 2. Anmeldungen, Abmeldungen und Kontoaenderungen des angemeldeten
|
|
* Benutzers. Die tragen keine betreuteId - sie haengen ja am Konto,
|
|
* nicht an der Akte. Wuerde man nur nach betreuteId filtern, fehlte
|
|
* ausgerechnet die Frage "wer hat wann auf die Akte zugegriffen".
|
|
*/
|
|
const filter = {
|
|
OR: [{ betreuteId: aktive.id }, { userId: benutzer.id, betreuteId: null }],
|
|
}
|
|
|
|
const [eintraege, gesamt] = await Promise.all([
|
|
db.auditLog.findMany({
|
|
where: filter,
|
|
orderBy: { zeitpunkt: 'desc' },
|
|
skip: (nummer - 1) * proSeite,
|
|
take: proSeite,
|
|
}),
|
|
db.auditLog.count({ where: filter }),
|
|
])
|
|
|
|
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SeitenKopf
|
|
titel="Protokoll"
|
|
beschreibung="Jede Änderung an den Daten dieser Betreuung und jede Anmeldung an deinem Zugang — lückenlos und in der Reihenfolge, in der sie geschehen ist."
|
|
/>
|
|
|
|
<Alert variant="hinweis">
|
|
<ShieldCheck aria-hidden="true" />
|
|
<AlertTitle>Unveränderlich — und das ist Absicht</AlertTitle>
|
|
<AlertDescription>
|
|
Dieses Protokoll kann niemand nachträglich ändern oder löschen, auch nicht mit direktem
|
|
Zugriff auf die Datenbank: ein Datenbank-Trigger weist jedes UPDATE und jedes DELETE ab.
|
|
Damit ist es dein Nachweis gegenüber dem Betreuungsgericht und gegenüber möglichen
|
|
Miterben, dass du sauber gearbeitet hast.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{eintraege.length === 0 ? (
|
|
<Leerzustand
|
|
titel="Noch keine Einträge"
|
|
beschreibung="Sobald du Daten anlegst oder änderst, steht es hier."
|
|
/>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-44">Zeitpunkt</TableHead>
|
|
<TableHead className="w-40">Wer</TableHead>
|
|
<TableHead className="w-40">Was</TableHead>
|
|
<TableHead>Gegenstand</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{eintraege.map((eintrag) => {
|
|
const roh = entschluesselnWennMoeglich(
|
|
eintrag.aenderungVerschluesselt,
|
|
'aenderungVerschluesselt',
|
|
)
|
|
let aenderung: Aenderung | null = null
|
|
try {
|
|
aenderung = roh ? (JSON.parse(roh) as Aenderung) : null
|
|
} catch {
|
|
aenderung = null
|
|
}
|
|
|
|
return (
|
|
<TableRow key={eintrag.id} className="align-top">
|
|
<TableCell className="whitespace-nowrap tabular-nums">
|
|
{formatDatumZeit(eintrag.zeitpunkt)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="block truncate">{eintrag.userName ?? '—'}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{eintrag.userEmail}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={variante(eintrag.aktion)}>
|
|
{AKTION_BESCHRIFTUNG[eintrag.aktion]}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="font-medium">{eintrag.entitaet}</span>
|
|
{eintrag.bezeichnung && (
|
|
<span className="text-muted-foreground"> · {eintrag.bezeichnung}</span>
|
|
)}
|
|
{aenderung && Object.keys(aenderung).length > 0 && (
|
|
<details className="mt-1.5">
|
|
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
|
{Object.keys(aenderung).length} Feld
|
|
{Object.keys(aenderung).length === 1 ? '' : 'er'} geändert
|
|
</summary>
|
|
<dl className="mt-2 space-y-1.5 border-l-2 pl-3 text-sm">
|
|
{Object.entries(aenderung).map(([feld, werte]) => (
|
|
<div key={feld}>
|
|
<dt className="text-xs font-medium text-muted-foreground">
|
|
{feld}
|
|
</dt>
|
|
<dd className="flex flex-wrap items-baseline gap-1.5">
|
|
<span className="text-muted-foreground line-through">
|
|
{alsText(werte.alt)}
|
|
</span>
|
|
<span aria-hidden="true">→</span>
|
|
<span className="font-medium">{alsText(werte.neu)}</span>
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</details>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{seiten > 1 && (
|
|
<nav aria-label="Seiten" className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
Seite {nummer} von {seiten} · {gesamt} Einträge
|
|
</span>
|
|
<span className="flex gap-2">
|
|
{nummer > 1 && (
|
|
<a className="underline hover:no-underline" href={`/protokoll?seite=${nummer - 1}`}>
|
|
Neuere
|
|
</a>
|
|
)}
|
|
{nummer < seiten && (
|
|
<a className="underline hover:no-underline" href={`/protokoll?seite=${nummer + 1}`}>
|
|
Ältere
|
|
</a>
|
|
)}
|
|
</span>
|
|
</nav>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|