Files
betreuungs-cockpit/src/components/konto/passkey-verwaltung.tsx
T
Florian Wilde c38c988c3c feat: Anmelden mit Passkey
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.
2026-08-22 18:44:56 +02:00

278 lines
8.5 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
AlertCircle,
CheckCircle2,
Fingerprint,
Loader2,
Plus,
Smartphone,
Trash2,
} from 'lucide-react'
import {
passkeyEinrichtungAbschliessen,
passkeyEinrichtungStarten,
passkeyEntfernen,
} from '@/server/actions/passkey'
import {
browserSupportsWebAuthn,
passkeyBrowserfehler,
startRegistration,
} from '@/lib/passkey-browser'
import type { PasskeyUebersicht } from '@/lib/auth/passkey'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
import { formatDatum, formatDatumZeit } from '@/lib/utils'
/**
* Verwaltung der eigenen Passkeys unter "Mein Konto".
*
* Die Liste kommt vom Server; nach jeder Aenderung wird die Seite neu
* geladen (router.refresh) statt den Zustand hier mitzufuehren. Bei einer
* Handvoll Eintraegen ist das die ehrlichere Loesung: Was auf dem Bildschirm
* steht, steht auch so in der Datenbank.
*/
export function PasskeyVerwaltung({
passkeys,
zweiterFaktorAktiv,
moeglich,
}: {
passkeys: PasskeyUebersicht[]
zweiterFaktorAktiv: boolean
/** Ob der Server Passkeys ueberhaupt anbieten kann (https). */
moeglich: boolean
}) {
const router = useRouter()
const [browserKann, setBrowserKann] = useState(false)
const [name, setName] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [entfernt, setEntfernt] = useState<string | null>(null)
const [fehler, setFehler] = useState<string | null>(null)
const [erfolg, setErfolg] = useState<string | null>(null)
const [nachfrage, setNachfrage] = useState<string | null>(null)
useEffect(() => {
setBrowserKann(browserSupportsWebAuthn())
}, [])
async function einrichten() {
setFehler(null)
setErfolg(null)
if (name.trim() === '') {
setFehler('Bitte gib dem Gerät zuerst einen Namen.')
return
}
setLaeuft(true)
try {
const start = await passkeyEinrichtungStarten()
if (!start.ok) {
setFehler(start.fehler)
return
}
const antwort = await startRegistration({ optionsJSON: start.optionen })
const ergebnis = await passkeyEinrichtungAbschliessen(name, JSON.stringify(antwort))
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
setName('')
setErfolg(ergebnis.meldung ?? 'Passkey eingerichtet.')
router.refresh()
} catch (ausnahme) {
setFehler(passkeyBrowserfehler(ausnahme, true))
} finally {
setLaeuft(false)
}
}
async function entfernen(id: string) {
setFehler(null)
setErfolg(null)
setNachfrage(null)
setEntfernt(id)
try {
const ergebnis = await passkeyEntfernen(id)
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
setErfolg(ergebnis.meldung ?? 'Passkey entfernt.')
router.refresh()
} finally {
setEntfernt(null)
}
}
if (!moeglich) {
return (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation ist nicht über
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">https</code>
erreichbar — sobald sie es ist, erscheint hier die Verwaltung.
</AlertDescription>
</Alert>
)
}
if (!zweiterFaktorAktiv) {
return (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ginge das
verloren und wäre er der einzige Weg hinein, käme niemand mehr an die Akte. Der Passkey
ist die bequeme Tür, nicht die einzige.
</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-4">
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
{erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{erfolg}</AlertDescription>
</Alert>
)}
{passkeys.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch kein Passkey eingerichtet. Du meldest dich weiterhin mit E-Mail, Passwort und Code an
— das bleibt auch danach möglich.
</p>
) : (
<ul className="divide-y rounded-md border">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex flex-wrap items-start gap-3 p-3">
<Smartphone
className="mt-0.5 size-5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="font-medium">{passkey.name}</p>
<p className="text-xs text-muted-foreground">
Eingerichtet am {formatDatum(passkey.erstelltAm)}
{passkey.zuletztBenutztAm
? ` · zuletzt benutzt ${formatDatumZeit(passkey.zuletztBenutztAm)}`
: ' · noch nicht benutzt'}
</p>
{passkey.geraeteArt === 'singleDevice' && (
<p className="mt-1 text-xs text-muted-foreground">
Liegt nur auf diesem Gerät. Geht es verloren, ist auch der Passkey weg.
</p>
)}
</div>
{passkey.gesichert && (
<Badge variant="secondary" className="shrink-0">
gesichert
</Badge>
)}
{nachfrage === passkey.id ? (
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void entfernen(passkey.id)}
disabled={entfernt === passkey.id}
>
{entfernt === passkey.id ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : null}
Wirklich entfernen
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setNachfrage(null)}
>
Abbrechen
</Button>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => setNachfrage(passkey.id)}
aria-label={`Passkey „${passkey.name}“ entfernen`}
>
<Trash2 aria-hidden="true" />
Entfernen
</Button>
)}
</li>
))}
</ul>
)}
{browserKann ? (
<div className="space-y-3 rounded-md border border-dashed p-3">
<Feld
id="passkey-name"
beschriftung="Neues Gerät"
erklaerung="Ein Name, an dem du es später wiedererkennst — etwa „iPhone“ oder „Laptop Büro“."
>
<Input
id="passkey-name"
value={name}
onChange={(ereignis) => setName(ereignis.target.value)}
maxLength={60}
placeholder="iPhone"
autoComplete="off"
/>
</Feld>
<Button type="button" onClick={() => void einrichten()} disabled={laeuft}>
{laeuft ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Warte auf das Gerät …
</>
) : (
<>
<Plus aria-hidden="true" />
Passkey einrichten
</>
)}
</Button>
</div>
) : (
<Alert variant="hinweis">
<Fingerprint aria-hidden="true" />
<AlertDescription>
Dieser Browser kann keine Passkeys. Auf einem anderen Gerät klappt es vermutlich — die
bereits eingerichteten bleiben davon unberührt.
</AlertDescription>
</Alert>
)}
</div>
)
}