Jeder erzeugte Brief ging bisher ohne Ruecksendeadresse hinaus: Im Absenderblock stand nur der Name, in der Datumszeile fehlte der Ort. Bei einer Versicherung oder Behoerde wirkt das unserioes und macht es der Gegenstelle unnoetig schwer zu antworten. Neu unter "Mein Konto" ein Feld fuer Strasse, PLZ und Ort. Ab da stehen sie im Absenderblock jedes Schreibens, und die Datumszeile lautet "Bayreuth, den 23.08.2026". Die Anschrift haengt am Benutzer, nicht an der betreuten Person: Sie gehoert zu mir und gilt fuer alle Akten. Nicht die Logik fehlte, sondern die Datenquelle - absenderBlock() und datumsZeile() konnten die Felder schon, briefkopfBauen() hat sie mit einem Kommentar auf null gesetzt, weil es sie im Datenmodell nicht gab. Aufbau: - Drei Felder am Benutzer, Migration ueber den Wrapper aus #24. Das war sein erster echter Einsatz: erzeugte Datei ohne Handarbeit brauchbar, DROP-INDEX-Zeilen bereits heraus. - Ein Absender-Typ statt eines blossen Namens, durchgereicht bis in den Serienlauf - der hat bisher {name} gebaut und haette die Anschrift sonst still verloren. - Zwei Platzhalter fuer eigene Vorlagen: betreuer_anschrift, betreuer_ort. Was fehlt, bleibt weg: Jede der drei Angaben ist einzeln freiwillig. Nur ein Ort ergibt eine Datumszeile mit Ort und einen Absenderblock, der weiterhin nur aus dem Namen besteht. Eine halb ausgefuellte Adresse sieht nach Fehler aus, ein blosser Name nach Absicht. Geprueft: sieben Einheitstests fuer den Absenderblock inklusive jeder Teilbefuellung und der Gegenprobe, dass nie die Heimadresse der betreuten Person im Absender landet; vier End-to-End-Tests vom leeren Zustand ueber das Speichern bis zum fertigen Brief. Refs #21
185 lines
5.3 KiB
TypeScript
185 lines
5.3 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { z } from 'zod'
|
|
import { db } from '@/lib/db'
|
|
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
|
import { passwortHashen, passwortPruefen } from '@/lib/crypto/hash'
|
|
import { protokollieren } from '@/lib/audit'
|
|
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
|
|
|
|
/**
|
|
* Passwortwechsel.
|
|
*
|
|
* Danach wird sitzungsGeneration hochgezaehlt. Das macht alle bestehenden
|
|
* Sitzungen ungueltig - auch die auf Geraeten, an denen man gerade nicht
|
|
* sitzt. Genau darum aendert man ja das Passwort.
|
|
*
|
|
* Die eigene Sitzung faellt dabei mit; der Benutzer meldet sich neu an.
|
|
* Das ist unbequem und trotzdem richtig: ein Wechsel, der offene Sitzungen
|
|
* stehen laesst, schuetzt vor nichts.
|
|
*/
|
|
|
|
const PASSWORT_MINDESTLAENGE = 12
|
|
|
|
const PasswortSchema = z
|
|
.object({
|
|
aktuell: z.string().min(1, 'Bitte das bisherige Passwort eingeben.'),
|
|
neu: z
|
|
.string()
|
|
.min(
|
|
PASSWORT_MINDESTLAENGE,
|
|
`Bitte mindestens ${PASSWORT_MINDESTLAENGE} Zeichen. Vier zufällige Wörter sind ` +
|
|
`leichter zu merken und sicherer als ein kurzes Kunstwort.`,
|
|
)
|
|
.max(200),
|
|
wiederholung: z.string(),
|
|
})
|
|
.refine((daten) => daten.neu === daten.wiederholung, {
|
|
message: 'Die beiden neuen Passwörter stimmen nicht überein.',
|
|
path: ['wiederholung'],
|
|
})
|
|
.refine((daten) => daten.neu !== daten.aktuell, {
|
|
message: 'Das neue Passwort muss sich vom bisherigen unterscheiden.',
|
|
path: ['neu'],
|
|
})
|
|
|
|
export type PasswortZustand = {
|
|
fehler?: string
|
|
felder?: Partial<Record<'aktuell' | 'neu' | 'wiederholung', string>>
|
|
erfolg?: boolean
|
|
}
|
|
|
|
export async function passwortAendern(
|
|
_vorher: PasswortZustand,
|
|
formular: FormData,
|
|
): Promise<PasswortZustand> {
|
|
const begonnen = Date.now()
|
|
const benutzer = await erfordereBenutzer()
|
|
|
|
const gelesen = PasswortSchema.safeParse({
|
|
aktuell: formular.get('aktuell'),
|
|
neu: formular.get('neu'),
|
|
wiederholung: formular.get('wiederholung'),
|
|
})
|
|
|
|
if (!gelesen.success) {
|
|
const felder = gelesen.error.flatten().fieldErrors
|
|
return {
|
|
fehler: 'Bitte prüfe deine Eingaben.',
|
|
felder: {
|
|
aktuell: felder.aktuell?.[0],
|
|
neu: felder.neu?.[0],
|
|
wiederholung: felder.wiederholung?.[0],
|
|
},
|
|
}
|
|
}
|
|
|
|
const stimmt = await passwortPruefen(benutzer.passwortHash, gelesen.data.aktuell)
|
|
if (!stimmt) {
|
|
await aufMindestdauerWarten(begonnen)
|
|
return { fehler: 'Das bisherige Passwort stimmt nicht.', felder: { aktuell: ' ' } }
|
|
}
|
|
|
|
await db.user.update({
|
|
where: { id: benutzer.id },
|
|
data: {
|
|
passwortHash: await passwortHashen(gelesen.data.neu),
|
|
passwortGeaendertAm: new Date(),
|
|
// Beendet alle bestehenden Sitzungen, die eigene eingeschlossen.
|
|
sitzungsGeneration: { increment: 1 },
|
|
},
|
|
})
|
|
|
|
await protokollieren({
|
|
aktion: 'PASSWORT_GEAENDERT',
|
|
entitaet: 'User',
|
|
entitaetId: benutzer.id,
|
|
userId: benutzer.id,
|
|
userEmail: benutzer.email,
|
|
userName: benutzer.name,
|
|
})
|
|
|
|
await aufMindestdauerWarten(begonnen)
|
|
return { erfolg: true }
|
|
}
|
|
|
|
/**
|
|
* Die eigene Anschrift.
|
|
*
|
|
* Sie steht im Absenderblock jedes erzeugten Schreibens und liefert den
|
|
* Ort fuer die Datumszeile. Angaben sind einzeln freiwillig: Wer nur den
|
|
* Ort hinterlegt, bekommt "Bayreuth, den 23.08.2026" und keinen
|
|
* Absenderblock mit leeren Zeilen.
|
|
*/
|
|
const AnschriftSchema = z.object({
|
|
strasse: z.string().trim().max(150, 'Bitte höchstens 150 Zeichen.'),
|
|
plz: z.string().trim().max(10, 'Bitte höchstens 10 Zeichen.'),
|
|
ort: z.string().trim().max(100, 'Bitte höchstens 100 Zeichen.'),
|
|
})
|
|
|
|
export type AnschriftZustand = {
|
|
fehler?: string
|
|
felder?: Partial<Record<'strasse' | 'plz' | 'ort', string>>
|
|
erfolg?: string
|
|
}
|
|
|
|
export async function anschriftSpeichern(
|
|
_vorher: AnschriftZustand,
|
|
formular: FormData,
|
|
): Promise<AnschriftZustand> {
|
|
const benutzer = await erfordereBenutzer()
|
|
|
|
const gelesen = AnschriftSchema.safeParse({
|
|
strasse: formular.get('strasse') ?? '',
|
|
plz: formular.get('plz') ?? '',
|
|
ort: formular.get('ort') ?? '',
|
|
})
|
|
|
|
if (!gelesen.success) {
|
|
const felder = gelesen.error.flatten().fieldErrors
|
|
return {
|
|
fehler: 'Bitte prüfe die Eingaben.',
|
|
felder: {
|
|
strasse: felder.strasse?.[0],
|
|
plz: felder.plz?.[0],
|
|
ort: felder.ort?.[0],
|
|
},
|
|
}
|
|
}
|
|
|
|
// Leer heisst "nicht hinterlegt", nicht "leere Zeichenkette" - sonst
|
|
// stuende im Brief eine Zeile aus Leerzeichen.
|
|
const leerZuNull = (wert: string) => (wert === '' ? null : wert)
|
|
|
|
await db.user.update({
|
|
where: { id: benutzer.id },
|
|
data: {
|
|
strasse: leerZuNull(gelesen.data.strasse),
|
|
plz: leerZuNull(gelesen.data.plz),
|
|
ort: leerZuNull(gelesen.data.ort),
|
|
},
|
|
})
|
|
|
|
await protokollieren({
|
|
aktion: 'GEAENDERT',
|
|
entitaet: 'User',
|
|
entitaetId: benutzer.id,
|
|
bezeichnung: benutzer.name,
|
|
userId: benutzer.id,
|
|
userEmail: benutzer.email,
|
|
userName: benutzer.name,
|
|
aenderung: {
|
|
anschrift: {
|
|
alt: [benutzer.strasse, benutzer.plz, benutzer.ort].filter(Boolean).join(', ') || null,
|
|
neu:
|
|
[gelesen.data.strasse, gelesen.data.plz, gelesen.data.ort].filter(Boolean).join(', ') ||
|
|
null,
|
|
},
|
|
},
|
|
})
|
|
|
|
revalidatePath('/konto')
|
|
return { erfolg: 'Deine Anschrift ist gespeichert.' }
|
|
}
|