Files
betreuungs-cockpit/src/app/(app)/konto/page.tsx
T
Florian Wilde 848aaf1b9f Absenderanschrift in jedem Schreiben
Jeder erzeugte Brief ging bisher ohne Ruecksendeadresse hinaus: Im
Absenderblock stand nur der Name, in der Datumszeile fehlte der Ort. Bei
einer Versicherung oder Behoerde wirkt das unserioes und macht es der
Gegenstelle unnoetig schwer zu antworten.

Neu unter "Mein Konto" ein Feld fuer Strasse, PLZ und Ort. Ab da stehen
sie im Absenderblock jedes Schreibens, und die Datumszeile lautet
"Bayreuth, den 23.08.2026".

Die Anschrift haengt am Benutzer, nicht an der betreuten Person: Sie
gehoert zu mir und gilt fuer alle Akten.

Nicht die Logik fehlte, sondern die Datenquelle - absenderBlock() und
datumsZeile() konnten die Felder schon, briefkopfBauen() hat sie mit
einem Kommentar auf null gesetzt, weil es sie im Datenmodell nicht gab.

Aufbau:
- Drei Felder am Benutzer, Migration ueber den Wrapper aus #24. Das war
  sein erster echter Einsatz: erzeugte Datei ohne Handarbeit brauchbar,
  DROP-INDEX-Zeilen bereits heraus.
- Ein Absender-Typ statt eines blossen Namens, durchgereicht bis in den
  Serienlauf - der hat bisher {name} gebaut und haette die Anschrift
  sonst still verloren.
- Zwei Platzhalter fuer eigene Vorlagen: betreuer_anschrift,
  betreuer_ort.

Was fehlt, bleibt weg: Jede der drei Angaben ist einzeln freiwillig. Nur
ein Ort ergibt eine Datumszeile mit Ort und einen Absenderblock, der
weiterhin nur aus dem Namen besteht. Eine halb ausgefuellte Adresse
sieht nach Fehler aus, ein blosser Name nach Absicht.

Geprueft: sieben Einheitstests fuer den Absenderblock inklusive jeder
Teilbefuellung und der Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet; vier End-to-End-Tests vom leeren
Zustand ueber das Speichern bis zum fertigen Brief.

Refs #21
2026-08-23 20:29:17 +02:00

160 lines
6.2 KiB
TypeScript

import type { Metadata } from 'next'
import Link from 'next/link'
import { Fingerprint, KeyRound, MapPin, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
import { passkeysDesBenutzers, passkeysMoeglich } from '@/lib/auth/passkey'
import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular'
import { AnschriftFormular } from './anschrift-formular'
export const metadata: Metadata = { title: 'Mein Konto' }
export const dynamic = 'force-dynamic'
export default async function KontoSeite() {
const benutzer = await erfordereBenutzer()
const codesUebrig = benutzer.wiederherstellungscodes.length
const passkeys = await passkeysDesBenutzers(benutzer.id)
return (
<div className="max-w-2xl space-y-6">
<SeitenKopf
titel="Mein Konto"
beschreibung="Zugangsdaten, eigene Anschrift, zweiter Faktor und Passkeys."
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Zugang</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">Name</dt>
<dd className="font-medium">{benutzer.name}</dd>
</div>
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">E-Mail</dt>
<dd className="font-medium">{benutzer.email}</dd>
</div>
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">Rolle</dt>
<dd className="text-right">
<Badge variant="secondary">{ROLLEN_BESCHRIFTUNG[benutzer.rolle]}</Badge>
<p className="mt-1 text-xs text-muted-foreground">
{ROLLEN_ERKLAERUNG[benutzer.rolle]}
</p>
</dd>
</div>
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">Letzte Anmeldung</dt>
<dd className="font-medium">{formatDatumZeit(benutzer.letzteAnmeldung)}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-5" aria-hidden="true" />
Meine Anschrift
</CardTitle>
<CardDescription>
Steht im Absenderblock jedes Schreibens, das hier entsteht, und liefert den Ort für
die Datumszeile. Ohne sie geht ein förmlicher Brief ohne Rücksendeadresse hinaus.
Jede Angabe ist einzeln freiwillig — was fehlt, bleibt im Brief einfach weg.
</CardDescription>
</CardHeader>
<CardContent>
<AnschriftFormular
strasse={benutzer.strasse}
plz={benutzer.plz}
ort={benutzer.ort}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="size-5" aria-hidden="true" />
Zweiter Faktor
</CardTitle>
<CardDescription>
{benutzer.totpAktiviertAm
? `Eingerichtet am ${formatDatumZeit(benutzer.totpAktiviertAm)}.`
: 'Noch nicht eingerichtet.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">
{codesUebrig > 0 ? (
<>
Noch <strong>{codesUebrig}</strong> Wiederherstellungscode
{codesUebrig === 1 ? '' : 's'} übrig.
{codesUebrig <= 2 && (
<span className="ml-1 text-ampel-orange">
Es wird Zeit, neue zu erzeugen.
</span>
)}
</>
) : (
<span className="text-ampel-rot">
Keine Wiederherstellungscodes mehr übrig. Geht dein Handy verloren, kommst du
nicht mehr an die Daten. Bitte gleich neue erzeugen.
</span>
)}
</p>
<Button asChild variant="outline">
<Link href="/konto/zwei-faktor">Zweiten Faktor verwalten</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Fingerprint className="size-5" aria-hidden="true" />
Passkeys
</CardTitle>
<CardDescription>
Anmelden mit Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail, Passwort und Code.
Ein Passkey gilt als zweiter Faktor, weil er Besitz des Geräts und dessen Entsperrung
verlangt. Er ist an diese Adresse gebunden: Auf einer nachgebauten Anmeldeseite
funktioniert er nicht.
</CardDescription>
</CardHeader>
<CardContent>
<PasskeyVerwaltung
passkeys={passkeys}
zweiterFaktorAktiv={Boolean(benutzer.totpAktiviertAm)}
moeglich={passkeysMoeglich()}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="size-5" aria-hidden="true" />
Passwort ändern
</CardTitle>
<CardDescription>
Nach dem Ändern werden alle anderen offenen Sitzungen beendet — auch auf Geräten, an
denen du gerade nicht sitzt.
</CardDescription>
</CardHeader>
<CardContent>
<PasswortFormular />
</CardContent>
</Card>
</div>
)
}