feat: Oberflaechenbausteine, Anwendungsrahmen und Konventionen

Basisbausteine im shadcn/ui-Stil, aber mit drei bewussten Abweichungen:

- Bedienelemente sind 44px hoch statt 36px. Ich bediene die App oft am
  Handy im Heim oder auf dem Amt; kleiner ist dort nicht treffsicher.
- Auswahlfelder benutzen das native <select>. Android und iOS oeffnen
  dafuer ihre eigene Liste - schneller als jeder Nachbau, und mit
  Screenreader und Tastatur funktioniert es ohne Zutun.
- Eingabefelder haben mindestens 16px Schrift, sonst zoomt iOS-Safari
  beim Hineintippen das Formular heran.

<Feld> verdrahtet Beschriftung, Erklaerung und Fehler ueber
aria-describedby. Der Erklaerungstext ist kein Beiwerk: die Anwendung
benutzt Begriffe aus dem Betreuungsrecht, die niemand nachschlagen soll.

<Rechtshinweis> ist der stehende Vermerk, dass hier keine Rechtsberatung
stattfindet. Er steht an jeder Stelle mit Paragraphenbezug.

Der Anwendungsrahmen fuehrt drei Bedingungen zusammen, ohne die niemand
an Fachdaten kommt: angemeldet, zweiter Faktor eingerichtet, mindestens
eine betreute Person zugeordnet. Die Navigation zeigt Zaehler fuer
ueberfaellige Fristen und neuen Posteingang - die Frage "wo muss ich
hin?" wird schon im Menue beantwortet.

Das Farbschema wird per Inline-Skript mit CSP-Nonce gesetzt, bevor die
Seite gezeichnet wird - sonst blitzt beim Laden das helle Thema auf.

docs/KONVENTIONEN.md haelt fest, was in allen weiteren Etappen gilt:
Verzeichnisaufbau, Cent-Betraege, Umgang mit verschluesselten Feldern,
Zugriffspruefung, Audit-Pflicht, Ampelfarben, Commit-Trailer.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:54:56 +02:00
parent bd1c7b801f
commit dcd3a8ce71
30 changed files with 2151 additions and 12 deletions
+84
View File
@@ -0,0 +1,84 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2, LogIn } from 'lucide-react'
import { anmelden, type AnmeldeZustand } from '@/server/actions/anmelden'
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 Absendeknopf() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird geprüft …
</>
) : (
<>
<LogIn aria-hidden="true" />
Anmelden
</>
)}
</Button>
)
}
export function AnmeldeFormular({ weiter }: { weiter: string }) {
const [zustand, aktion] = useActionState<AnmeldeZustand, FormData>(anmelden, {})
return (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="email" beschriftung="E-Mail-Adresse" fehler={zustand.felder?.email} pflicht>
<Input
name="email"
type="email"
autoComplete="username"
inputMode="email"
autoCapitalize="none"
spellCheck={false}
defaultValue={zustand.email}
required
autoFocus
/>
</Feld>
<Feld id="passwort" beschriftung="Passwort" fehler={zustand.felder?.passwort} pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required />
</Feld>
<Feld
id="code"
beschriftung="Code aus der Authenticator-App"
erklaerung="Sechs Ziffern. Alternativ ein Wiederherstellungscode im Format XXXX-XXXX-XXXX."
pflicht
>
<Input
name="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
// Kein maxLength: Wiederherstellungscodes sind laenger.
placeholder="123456"
className="font-mono tracking-widest"
required
/>
</Feld>
<Absendeknopf />
</form>
)
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { ShieldCheck } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { AnmeldeFormular } from './formular'
export const metadata: Metadata = { title: 'Anmelden' }
/** Fachdaten dürfen nie aus einem Cache kommen. */
export const dynamic = 'force-dynamic'
export default async function AnmeldenSeite({
searchParams,
}: {
searchParams: Promise<{ weiter?: string }>
}) {
// Wer schon angemeldet ist, soll nicht am Anmeldeformular hängen bleiben.
if (await aktuellerBenutzer()) redirect('/heute')
// Gibt es überhaupt schon einen Zugang? Beim allerersten Start nicht.
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
if (anzahlBenutzer === 0) redirect('/ersteinrichtung')
const { weiter = '/heute' } = await searchParams
return (
<>
<Card>
<CardHeader>
<CardTitle>Anmelden</CardTitle>
<CardDescription>
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
</CardDescription>
</CardHeader>
<CardContent>
<AnmeldeFormular weiter={weiter} />
</CardContent>
</Card>
<div className="flex items-start gap-2.5 rounded-md border bg-muted/50 p-3 text-sm text-muted-foreground">
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
deshalb Pflicht und lässt sich nicht abschalten.
</p>
</div>
</>
)
}
+38
View File
@@ -0,0 +1,38 @@
import Link from 'next/link'
/**
* Rahmen fuer Anmeldung, Ersteinrichtung und Zweitfaktor.
* Bewusst schmal und ruhig - hier gibt es genau eine Aufgabe.
*/
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh flex-col">
<main className="flex flex-1 items-center justify-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<div className="flex items-center justify-center gap-3">
<span
aria-hidden="true"
className="grid size-10 place-items-center rounded-lg bg-primary font-semibold text-primary-foreground"
>
BC
</span>
<span className="text-xl font-semibold">Betreuungs-Cockpit</span>
</div>
{children}
</div>
</main>
<footer className="border-t px-4 py-4">
<div className="mx-auto flex max-w-md flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<Link href="/datenschutz" className="hover:underline">
Datenschutz
</Link>
<span aria-hidden="true">·</span>
<span>Daten bleiben in der EU</span>
<span aria-hidden="true">·</span>
<span>Keine Rechtsberatung</span>
</div>
</footer>
</div>
)
}