feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
@@ -0,0 +1,150 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { betreuteAnlegen, type BetreuteZustand } from '@/server/actions/betreute'
import { AUFGABENKREISE, AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird angelegt …' : 'Betreuung anlegen und loslegen'}
</Button>
)
}
/** Nur die vier häufigsten Aufgabenkreise werden vorangestellt. */
const HAEUFIG = ['VERMOEGENSSORGE', 'GESUNDHEITSSORGE', 'AUFENTHALTSBESTIMMUNG', 'WOHNUNGSANGELEGENHEITEN']
export function BetreuungFormular() {
const [zustand, aktion] = useActionState<BetreuteZustand, FormData>(betreuteAnlegen, {})
return (
<form action={aktion} className="space-y-5" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="vorname" beschriftung="Vorname" fehler={zustand.felder?.vorname} pflicht>
<Input name="vorname" defaultValue={zustand.werte?.vorname} required autoFocus />
</Feld>
<Feld id="nachname" beschriftung="Nachname" fehler={zustand.felder?.nachname} pflicht>
<Input name="nachname" defaultValue={zustand.werte?.nachname} required />
</Feld>
</div>
<Feld
id="geburtsdatum"
beschriftung="Geburtsdatum"
fehler={zustand.felder?.geburtsdatum}
pflicht
>
<Input
name="geburtsdatum"
type="date"
defaultValue={zustand.werte?.geburtsdatum}
required
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="betreuungsgericht"
beschriftung="Betreuungsgericht"
fehler={zustand.felder?.betreuungsgericht}
>
<Input
name="betreuungsgericht"
placeholder="Amtsgericht …"
defaultValue={zustand.werte?.betreuungsgericht}
/>
</Feld>
<Feld
id="aktenzeichenGericht"
beschriftung="Aktenzeichen"
fehler={zustand.felder?.aktenzeichenGericht}
>
<Input
name="aktenzeichenGericht"
placeholder="XVII 123/25"
defaultValue={zustand.werte?.aktenzeichenGericht}
/>
</Feld>
</div>
<Feld
id="bestellungAm"
beschriftung="Datum der Bestellung zum Betreuer"
erklaerung="Steht in deiner Bestellungsurkunde. Daraus ergibt sich das Betreuungsjahr — und damit, wann Anfangsbericht, Jahresbericht und die Aufwandspauschale fällig sind."
fehler={zustand.felder?.bestellungAm}
>
<Input name="bestellungAm" type="date" defaultValue={zustand.werte?.bestellungAm} />
</Feld>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Aufgabenkreise</legend>
<p className="text-sm text-muted-foreground">
Die in der Bestellungsurkunde genannten Bereiche.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{[...HAEUFIG, ...AUFGABENKREISE.filter((a) => !HAEUFIG.includes(a))].map((kreis) => (
<label
key={kreis}
className="flex cursor-pointer items-center gap-2.5 rounded-md border p-2.5 hover:bg-accent"
>
<Checkbox
name="aufgabenkreise"
value={kreis}
defaultChecked={
kreis === 'VERMOEGENSSORGE' || kreis === 'GESUNDHEITSSORGE'
}
/>
<span className="text-sm">
{AUFGABENKREIS_BESCHRIFTUNG[kreis as keyof typeof AUFGABENKREIS_BESCHRIFTUNG]}
</span>
</label>
))}
</div>
</fieldset>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="befreiterBetreuer" defaultChecked className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Befreiter Betreuer (§ 1859 BGB)</span>
<span className="mt-0.5 block text-muted-foreground">
Trifft auf Angehörige in der Regel zu. Statt der vollständigen Rechnungslegung genügt
dann eine Vermögensübersicht.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="ehrenamtlich" defaultChecked className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Ehrenamtliche Betreuung</span>
<span className="mt-0.5 block text-muted-foreground">
Dann steht dir die jährliche Aufwandspauschale nach § 1878 BGB zu — die Anwendung
erinnert dich rechtzeitig, weil der Anspruch sonst verfällt.
</span>
</span>
</label>
<Rechtshinweis rechtsgrundlage="§§ 1859, 1863, 1878 BGB" />
<Absenden />
</form>
)
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { KeyRound, UserPlus } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { aktuellerBenutzer, meineBetreuten } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { ZugangFormular } from './zugang-formular'
import { BetreuungFormular } from './betreuung-formular'
export const metadata: Metadata = { title: 'Ersteinrichtung' }
export const dynamic = 'force-dynamic'
/**
* Ersteinrichtung in drei Schritten.
*
* Diese Seite liegt bewusst ausserhalb des Anwendungsrahmens: der Rahmen
* setzt einen Benutzer, einen zweiten Faktor UND eine betreute Person
* voraus. Beim allerersten Start gibt es nichts davon.
*/
export default async function ErsteinrichtungSeite() {
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
// --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
if (anzahlBenutzer === 0) {
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="size-5" aria-hidden="true" />
Schritt 1 von 3: Dein Zugang
</CardTitle>
<CardDescription>
Dieser erste Zugang verwaltet auch die Anwendung. Danach richtest du den zweiten
Faktor ein und legst die betreute Person an.
</CardDescription>
</CardHeader>
<CardContent>
<ZugangFormular />
</CardContent>
</Card>
<Alert variant="hinweis">
<KeyRound aria-hidden="true" />
<AlertTitle>Zum Passwort</AlertTitle>
<AlertDescription>
Nimm vier zufällige Wörter statt eines kurzen Kunstworts —{' '}
<em>Kastanie-Waschbär-Turm-Grün</em> ist leichter zu merken und deutlich schwerer zu
knacken. Ein Passwortmanager ist noch besser.
</AlertDescription>
</Alert>
</>
)
}
const benutzer = await aktuellerBenutzer()
if (!benutzer) redirect('/anmelden')
// --- Schritt 2: Zweiter Faktor fehlt noch --------------------------------
if (!benutzer.totpAktiviertAm) redirect('/zwei-faktor/einrichten')
// --- Schritt 3: Noch keine betreute Person ------------------------------
const betreute = await meineBetreuten(benutzer)
if (betreute.length > 0) redirect('/heute')
return (
<Card>
<CardHeader>
<CardTitle>Schritt 3 von 3: Die betreute Person</CardTitle>
<CardDescription>
Nur das Nötigste — alles Weitere trägst du später in Ruhe unter Stammdaten nach. Das{' '}
<strong>Bestellungsdatum</strong> ist das wichtigste Feld: daraus berechnet die Anwendung
das Betreuungsjahr und damit deine gesetzlichen Fristen.
</CardDescription>
</CardHeader>
<CardContent>
<BetreuungFormular />
</CardContent>
</Card>
)
}
@@ -0,0 +1,86 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { erstenZugangAnlegen, type ZugangZustand } from '@/server/actions/ersteinrichtung'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird angelegt …' : 'Zugang anlegen'}
</Button>
)
}
export function ZugangFormular() {
const [zustand, aktion] = useActionState<ZugangZustand, FormData>(erstenZugangAnlegen, {})
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="name" beschriftung="Dein Name" fehler={zustand.felder?.name} pflicht>
<Input
name="name"
autoComplete="name"
defaultValue={zustand.werte?.name}
placeholder="Vorname Nachname"
required
autoFocus
/>
</Feld>
<Feld
id="email"
beschriftung="E-Mail-Adresse"
erklaerung="Damit meldest du dich an. Sie wird nicht nach außen versendet."
fehler={zustand.felder?.email}
pflicht
>
<Input
name="email"
type="email"
autoComplete="username"
inputMode="email"
autoCapitalize="none"
spellCheck={false}
defaultValue={zustand.werte?.email}
required
/>
</Feld>
<Feld
id="passwort"
beschriftung="Passwort"
erklaerung="Mindestens 12 Zeichen."
fehler={zustand.felder?.passwort}
pflicht
>
<Input name="passwort" type="password" autoComplete="new-password" required />
</Feld>
<Feld
id="passwortWiederholung"
beschriftung="Passwort wiederholen"
fehler={zustand.felder?.passwortWiederholung}
pflicht
>
<Input name="passwortWiederholung" type="password" autoComplete="new-password" required />
</Feld>
<Absenden />
</form>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'
import { useRouter } from 'next/navigation'
import { AlertCircle, Check, Copy, Loader2, ShieldCheck } from 'lucide-react'
import {
zweitenFaktorBestaetigen,
type BestaetigungsZustand,
} from '@/server/actions/zwei-faktor'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird geprüft …' : 'Code bestätigen und aktivieren'}
</Button>
)
}
/** Base32 in Vierergruppen — so tippt es sich von Hand deutlich sicherer. */
function gruppiert(secret: string): string {
return secret.replace(/(.{4})/g, '$1 ').trim()
}
export function EinrichtungsFormular({ qrCode, secret }: { qrCode: string; secret: string }) {
const [zustand, aktion] = useActionState<BestaetigungsZustand, FormData>(
zweitenFaktorBestaetigen,
{},
)
const [kopiert, setKopiert] = useState(false)
const router = useRouter()
// --- Nach erfolgreicher Aktivierung: die Codes, genau einmal ------------
if (zustand.wiederherstellungscodes) {
return (
<div className="space-y-5">
<Alert variant="erfolg">
<ShieldCheck aria-hidden="true" />
<AlertTitle>Zweiter Faktor ist aktiv</AlertTitle>
<AlertDescription>
Ab jetzt brauchst du bei jeder Anmeldung den Code aus deiner App.
</AlertDescription>
</Alert>
<div className="space-y-2">
<h3 className="font-semibold">Deine Wiederherstellungscodes</h3>
<p className="text-sm text-muted-foreground">
Der einzige Weg zurück, wenn das Handy mit der App verloren geht. Jeder Code
funktioniert genau einmal. <strong>Sie werden dir nie wieder angezeigt.</strong> Druck
sie aus und leg sie zu deinen Betreuungsunterlagen — nicht auf dasselbe Gerät.
</p>
</div>
<ul className="grid grid-cols-2 gap-2 rounded-md border bg-muted/50 p-4 font-mono text-sm">
{zustand.wiederherstellungscodes.map((code) => (
<li key={code} className="tracking-wide">
{code}
</li>
))}
</ul>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
void navigator.clipboard
.writeText(zustand.wiederherstellungscodes!.join('\n'))
.then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2500)
})
}}
>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Codes kopieren'}
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={() => window.print()}>
Drucken
</Button>
</div>
<Button
type="button"
size="lg"
className="w-full"
onClick={() => router.push('/ersteinrichtung')}
>
Ich habe die Codes gesichert — weiter
</Button>
</div>
)
}
// --- Einrichtung -------------------------------------------------------
return (
<div className="space-y-5">
<ol className="space-y-2 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">1.</span> Installiere eine
Authenticator-App — etwa <strong>Aegis</strong> (Android) oder{' '}
<strong>2FAS</strong> (iOS). Beide sind quelloffen und kommen ohne Konto aus.
</li>
<li>
<span className="font-medium text-foreground">2.</span> Scanne den Code oder tippe das
Geheimnis ab.
</li>
<li>
<span className="font-medium text-foreground">3.</span> Gib die sechs Ziffern ein, die
die App anzeigt.
</li>
</ol>
{qrCode && (
<div className="flex justify-center rounded-lg border bg-white p-4">
{/* Der QR-Code entsteht serverseitig und ist als Data-URI eingebettet -
kein externer Dienst, kein CDN. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCode} alt="QR-Code zum Einrichten der Authenticator-App" width={240} height={240} />
</div>
)}
<div className="space-y-1.5">
<p className="text-sm font-medium">Geheimnis zum Abtippen</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2.5 font-mono text-sm">
{gruppiert(secret)}
</code>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Geheimnis kopieren"
onClick={() => {
void navigator.clipboard.writeText(secret).then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2500)
})
}}
>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</Button>
</div>
</div>
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="code" beschriftung="Sechsstelliger Code aus der App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={7}
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
autoFocus
/>
</Feld>
<Absenden />
</form>
</div>
)
}
@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
import { zweitenFaktorVorbereiten } from '@/server/actions/zwei-faktor'
import { EinrichtungsFormular } from './formular'
export const metadata: Metadata = { title: 'Zweiten Faktor einrichten' }
export const dynamic = 'force-dynamic'
export default async function ZweiFaktorEinrichtenSeite() {
const benutzer = await aktuellerBenutzer()
if (!benutzer) redirect('/anmelden')
if (benutzer.totpAktiviertAm) redirect('/heute')
const daten = await zweitenFaktorVorbereiten()
return (
<Card>
<CardHeader>
<CardTitle>Schritt 2 von 3: Zweiter Faktor</CardTitle>
<CardDescription>
Hier liegen Gesundheits- und Vermögensdaten einer Person, die nicht selbst entscheiden
kann. Ein Passwort allein reicht dafür nicht — der zweite Faktor ist Pflicht und lässt
sich nicht abschalten.
</CardDescription>
</CardHeader>
<CardContent>
<EinrichtungsFormular qrCode={daten.qrCode} secret={daten.secret} />
</CardContent>
</Card>
)
}