'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> erfolg?: boolean } export async function passwortAendern( _vorher: PasswortZustand, formular: FormData, ): Promise { 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> erfolg?: string } export async function anschriftSpeichern( _vorher: AnschriftZustand, formular: FormData, ): Promise { 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.' } }