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
+101
View File
@@ -0,0 +1,101 @@
'use server'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { db } from '@/lib/db'
import { passwortHashen } from '@/lib/crypto/hash'
import { protokollieren } from '@/lib/audit'
/**
* Anlegen des allerersten Zugangs.
*
* Diese Aktion ist nur so lange erreichbar, wie es noch keinen Benutzer
* gibt. Danach sperrt sie sich selbst - sonst koennte sich jemand von
* aussen einen Administratorzugang anlegen.
*/
const PASSWORT_MINDESTLAENGE = 12
const ZugangSchema = z
.object({
name: z.string().trim().min(2, 'Bitte deinen Namen angeben.').max(120),
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse angeben.'),
passwort: z
.string()
.min(
PASSWORT_MINDESTLAENGE,
`Bitte mindestens ${PASSWORT_MINDESTLAENGE} Zeichen. Eine Folge aus vier zufälligen ` +
`Wörtern ist leichter zu merken und sicherer als ein kurzes Kunstwort.`,
)
.max(200, 'Höchstens 200 Zeichen.'),
passwortWiederholung: z.string(),
})
.refine((daten) => daten.passwort === daten.passwortWiederholung, {
message: 'Die beiden Passwörter stimmen nicht überein.',
path: ['passwortWiederholung'],
})
export type ZugangZustand = {
fehler?: string
felder?: Partial<Record<'name' | 'email' | 'passwort' | 'passwortWiederholung', string>>
werte?: { name?: string; email?: string }
}
export async function erstenZugangAnlegen(
_vorher: ZugangZustand,
formular: FormData,
): Promise<ZugangZustand> {
const vorhandene = await db.user.count()
if (vorhandene > 0) {
return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' }
}
const gelesen = ZugangSchema.safeParse({
name: formular.get('name'),
email: formular.get('email'),
passwort: formular.get('passwort'),
passwortWiederholung: formular.get('passwortWiederholung'),
})
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
return {
fehler: 'Bitte prüfe deine Eingaben.',
felder: {
name: felder.name?.[0],
email: felder.email?.[0],
passwort: felder.passwort?.[0],
passwortWiederholung: felder.passwortWiederholung?.[0],
},
werte: {
name: String(formular.get('name') ?? ''),
email: String(formular.get('email') ?? ''),
},
}
}
const { name, email, passwort } = gelesen.data
const benutzer = await db.user.create({
data: {
name,
email,
passwortHash: await passwortHashen(passwort),
// Der erste Zugang verwaltet auch die Anwendung.
rolle: 'ADMIN',
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'User',
entitaetId: benutzer.id,
bezeichnung: `${name} (erster Zugang)`,
userId: benutzer.id,
userEmail: email,
userName: name,
})
// Weiter zur Anmeldung: der zweite Faktor wird direkt danach eingerichtet.
redirect('/anmelden?neu=1')
}