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
+117
View File
@@ -0,0 +1,117 @@
'use server'
import { AuthError } from 'next-auth'
import { z } from 'zod'
import { signIn } from '@/auth'
/**
* Server Action fuer das Anmeldeformular.
*
* Die Fehlermeldung ist absichtlich unspezifisch: Ob die E-Mail unbekannt
* ist oder das Passwort falsch, geht niemanden etwas an, der davorsitzt.
* Nur die Sperre wird konkret benannt - sonst probiert man ratlos weiter.
*/
const AnmeldeSchema = z.object({
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse eingeben.'),
passwort: z.string().min(1, 'Bitte das Passwort eingeben.'),
code: z.string().trim().default(''),
weiter: z.string().default('/heute'),
})
export type AnmeldeZustand = {
fehler?: string
felder?: { email?: string; passwort?: string }
/** Vorbelegung, damit die E-Mail nach einem Fehlversuch stehen bleibt. */
email?: string
}
/**
* Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft.
* Bewusst ueber das digest-Feld statt ueber einen Import aus next/dist -
* interne Pfade aendern sich zwischen Nebenversionen.
*/
function istWeiterleitung(fehler: unknown): boolean {
return (
typeof fehler === 'object' &&
fehler !== null &&
'digest' in fehler &&
typeof (fehler as { digest?: unknown }).digest === 'string' &&
(fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT')
)
}
/** Verhindert offene Weiterleitungen auf fremde Seiten. */
function sicheresZiel(ziel: string): string {
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
return ziel
}
function fehlertext(code: string): string {
const [grund, wert] = code.split(':')
switch (grund) {
case 'gesperrt': {
const sekunden = Number.parseInt(wert ?? '0', 10)
const minuten = Math.ceil(sekunden / 60)
return (
`Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` +
`${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.`
)
}
case 'konto-inaktiv':
return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.'
case 'code-falsch':
return (
'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts stimmt — ' +
'TOTP-Codes hängen an der Uhr. Alternativ kannst du einen Wiederherstellungscode eingeben.'
)
default:
return 'E-Mail-Adresse, Passwort oder Code stimmen nicht.'
}
}
export async function anmelden(
_vorher: AnmeldeZustand,
formular: FormData,
): Promise<AnmeldeZustand> {
const gelesen = AnmeldeSchema.safeParse({
email: formular.get('email'),
passwort: formular.get('passwort'),
code: formular.get('code'),
weiter: formular.get('weiter') ?? '/heute',
})
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
return {
fehler: 'Bitte prüfe deine Eingaben.',
felder: { email: felder.email?.[0], passwort: felder.passwort?.[0] },
email: String(formular.get('email') ?? ''),
}
}
const { email, passwort, code, weiter } = gelesen.data
try {
await signIn('zugangsdaten', {
email,
passwort,
code,
redirectTo: sicheresZiel(weiter),
})
} catch (fehler) {
// Bei Erfolg wirft signIn eine Weiterleitung - die muss durch.
if (istWeiterleitung(fehler)) throw fehler
if (fehler instanceof AuthError) {
const code = 'code' in fehler && typeof fehler.code === 'string' ? fehler.code : 'daten-falsch'
return { fehler: fehlertext(code), email }
}
throw fehler
}
// Wird nicht erreicht - signIn leitet weiter.
return {}
}