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:
@@ -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 {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use server'
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { erfordereBenutzer, meineBetreuten, COOKIE_AKTIVE_BETREUTE } from '@/lib/auth/sitzung'
|
||||
|
||||
/**
|
||||
* Wechselt die aktive betreute Person.
|
||||
*
|
||||
* Die Auswahl wird nur gesetzt, wenn der Benutzer auf diese Person auch
|
||||
* Zugriff hat - sonst liesse sich ueber ein manipuliertes Cookie fremde
|
||||
* Daten anzeigen.
|
||||
*/
|
||||
export async function betreuteWechseln(betreuteId: string): Promise<void> {
|
||||
const benutzer = await erfordereBenutzer()
|
||||
const erlaubt = await meineBetreuten(benutzer)
|
||||
|
||||
if (!erlaubt.some((b) => b.id === betreuteId)) {
|
||||
throw new Error('Kein Zugriff auf diese betreute Person.')
|
||||
}
|
||||
|
||||
const keks = await cookies()
|
||||
keks.set(COOKIE_AKTIVE_BETREUTE, betreuteId, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
})
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user