Files
ClaudeandClaude Opus 5.5 18c3cdf3e5 Automatische Mails im neuen Design (HTML mit Textfassung)
Alle 22 automatischen Mails gehen jetzt als HTML nach den Vorlagen aus
server/mail-templates raus, dazu eine aus dem HTML erzeugte Textfassung
(multipart/alternative). Werte werden HTML-sicher eingesetzt, optionale
Blöcke (WENN/ENDE) je nach Fall ein- oder ausgeblendet.

- Neu: services/mailTemplates.js (Vorlagen lesen, Platzhalter, Blöcke,
  Textfassung); mailer.js nimmt { template, values, blocks }.
- K11 "Passwort geändert" war nicht im Design-Export: aus den Bausteinen
  von K02 zusammengesetzt.
- T09 (Widerruf, intern): Fälle "kein Abo gefunden" und "Stripe-Kündigung
  fehlgeschlagen" ergänzt; bisher stand dort immer "auf Kündigung gesetzt".
- Einheitlich: Datum TT.MM.JJJJ, Beträge deutsch (T04 "50,00 €"),
  "WEBklar" statt "Webklar", Anrede mit Kundennamen, Team-Absender
  "WEBklar Kundenportal", Betreff T05 "Kündigung (Stripe)".
- Logo für die Mails: public/webklar-logo.png (192 px, aus dem Design-SVG).

Tests: 52 neue (jede Vorlage: Kopf, Platzhalter, Blöcke, Rendern,
Escaping, Textfassung), npm test 97/97 auf Node 20.19.5; alle 40 Auslöser
einmal durch den Server geschickt (Appwrite-Mock, Stripe-Double,
SMTP-Falle mit STARTTLS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:37:50 +00:00

157 lines
7.3 KiB
JavaScript

import { deleteUserSession, getUserById, updateAppwriteUserPassword } from './appwriteAdmin.js'
import { verifyAppwritePassword } from './appwriteClient.js'
import { forgetStoredPortalPassword } from './customerAdmin.js'
import { sendCustomerMail } from './mailer.js'
import { rememberPasswordVersion } from './passwordVersion.js'
const fmtDateTime = (d) =>
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
// Appwrite verlangt mindestens 8 Zeichen und nimmt hoechstens 256 Byte
export const PASSWORD_MIN_LENGTH = 8
const PASSWORD_MAX_BYTES = 256
// Hoechstens 5 Pruefungen des aktuellen Passworts je Kunde und Stunde. Appwrite erlaubt 10 Sessions
// je E-Mail und Stunde (Anmeldungen zaehlen mit), die normale Anmeldung bleibt also moeglich.
const MAX_ATTEMPTS = 5
const ATTEMPT_WINDOW_MS = 60 * 60 * 1000
const attempts = new Map() // customerId -> Zeitpunkte der Pruefungen
function httpError(status, message, extra = {}) {
return Object.assign(new Error(message), { status }, extra)
}
/** Was am neuen Passwort nicht passt, oder ''. Dieselben Regeln prueft das Formular (app.js). */
export function newPasswordProblem(currentPassword, newPassword, email = '') {
if (!newPassword) return 'Bitte gib ein neues Passwort ein.'
if ([...newPassword].length < PASSWORD_MIN_LENGTH) return 'Das neue Passwort muss mindestens 8 Zeichen haben.'
if (Buffer.byteLength(newPassword, 'utf8') > PASSWORD_MAX_BYTES) return 'Das neue Passwort ist zu lang (höchstens 256 Zeichen).'
if (!newPassword.trim()) return 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.'
if (newPassword === currentPassword) return 'Das neue Passwort muss sich vom aktuellen unterscheiden.'
if (email && newPassword.trim().toLowerCase() === email.trim().toLowerCase()) {
return 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.'
}
return ''
}
/** Pruefungen des Kunden in der letzten Stunde; blockedFor = Sekunden bis zum naechsten Versuch. */
export function attemptState(customerId, now = Date.now()) {
const recent = (attempts.get(customerId) || []).filter((t) => now - t < ATTEMPT_WINDOW_MS)
if (recent.length) attempts.set(customerId, recent)
else attempts.delete(customerId)
const blockedFor =
recent.length >= MAX_ATTEMPTS ? Math.ceil((recent[recent.length - MAX_ATTEMPTS] + ATTEMPT_WINDOW_MS - now) / 1000) : 0
return { left: Math.max(0, MAX_ATTEMPTS - recent.length), blockedFor }
}
export function recordAttempt(customerId, now = Date.now()) {
attempts.set(customerId, [...(attempts.get(customerId) || []), now])
}
function minutesText(seconds) {
const minutes = Math.max(1, Math.ceil(seconds / 60))
return `${minutes} ${minutes === 1 ? 'Minute' : 'Minuten'}`
}
function tooManyAttempts(seconds) {
return httpError(429, `Zu viele Versuche. Bitte versuche es in ${minutesText(seconds)} noch einmal.`, {
retryAfterSeconds: seconds,
})
}
function updateError(err) {
switch (err.type) {
case 'password_recently_used':
return httpError(400, 'Dieses Passwort hast du vor Kurzem schon benutzt. Bitte wähle ein anderes.', { code: 'new_invalid' })
case 'password_personal_data':
return httpError(400, 'Das Passwort darf nicht deinen Namen, deine E-Mail-Adresse oder Telefonnummer enthalten.', { code: 'new_invalid' })
case 'general_argument_invalid':
// Die Laenge ist schon geprueft; bleibt Appwrites Liste haeufiger Passwoerter (falls eingeschaltet)
return httpError(400, 'Dieses Passwort ist zu leicht zu erraten. Bitte wähle ein anderes.', { code: 'new_invalid' })
default:
console.error('[auth] Neues Passwort nicht gesetzt:', err.status, err.type || '', err.message)
return httpError(502, 'Das Passwort konnte gerade nicht geändert werden. Bitte versuche es später noch einmal.')
}
}
function sendPasswordChangedMail(email, name, changedAt) {
return sendCustomerMail(email, {
template: 'K11',
values: { name: name || '', email, geaendert_am: fmtDateTime(changedAt) },
})
}
/**
* Kunde aendert sein Passwort im Kundenbereich: aktuelles Passwort pruefen, neues setzen,
* Klartext-Kopie des alten entfernen, Bestaetigung per Mail.
* @returns {Promise<{ passwordVersion: string }>} neuer Passwortstand fuer die eigene Session
*/
export async function changeCustomerPassword(session, body) {
const { customerId, appwriteUserId: userId } = session
const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : ''
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
const before = attemptState(customerId)
if (before.blockedFor > 0) throw tooManyAttempts(before.blockedFor)
if (!currentPassword) throw httpError(400, 'Bitte gib dein aktuelles Passwort ein.', { code: 'current_missing' })
const problem = newPasswordProblem(currentPassword, newPassword, session.email)
if (problem) throw httpError(400, problem, { code: 'new_invalid' })
// Angemeldet wird mit der E-Mail des Appwrite-Kontos (kann von customers.email abweichen)
const account = await getUserById(userId).catch(() => null)
if (!account?.email) {
throw httpError(502, 'Dein Zugang konnte gerade nicht geladen werden. Bitte versuche es später noch einmal.')
}
recordAttempt(customerId)
let check
try {
check = await verifyAppwritePassword(account.email, currentPassword)
} catch (err) {
if (err.status === 429) {
throw httpError(429, 'Zu viele Anmeldeversuche für deinen Zugang. Bitte versuche es in einer Stunde noch einmal.')
}
if (err.type === 'user_blocked') {
throw httpError(403, 'Dein Zugang ist gesperrt. Bitte wende dich an support@webklar.com.')
}
console.error('[auth] Aktuelles Passwort nicht prüfbar:', err.status, err.type || '', err.message)
throw httpError(502, 'Das Passwort konnte gerade nicht geprüft werden. Bitte versuche es später noch einmal.')
}
if (!check) {
const { left, blockedFor } = attemptState(customerId)
const hint = blockedFor > 0
? ` Weitere Versuche sind in ${minutesText(blockedFor)} wieder möglich.`
: left <= 2 ? ` Noch ${left} ${left === 1 ? 'Versuch' : 'Versuche'}.` : ''
throw httpError(400, `Das aktuelle Passwort stimmt nicht.${hint}`, { code: 'wrong_password' })
}
// Die Session diente nur der Pruefung
if (check.sessionId) {
await deleteUserSession(check.userId || userId, check.sessionId).catch((err) =>
console.warn('[auth] Prüf-Session nicht gelöscht:', err.message))
}
if (check.userId && check.userId !== userId) {
throw httpError(409, 'Dein Zugang passt nicht zu dieser Anmeldung. Bitte melde dich ab und neu an.')
}
let updated
try {
updated = await updateAppwriteUserPassword(userId, newPassword)
} catch (err) {
throw updateError(err)
}
const passwordVersion = updated?.passwordUpdate || ''
rememberPasswordVersion(userId, passwordVersion)
console.log(`[auth] Kunde ${customerId} hat sein Passwort geändert`)
try {
await forgetStoredPortalPassword(customerId)
} catch (err) {
console.error(`[auth] Alte Passwort-Kopie bei Kunde ${customerId} nicht entfernt:`, err.message)
}
sendPasswordChangedMail(account.email, session.name || account.name, passwordVersion || new Date()).catch((err) =>
console.error('[auth] Mail "Passwort geändert" fehlgeschlagen:', err.message))
return { passwordVersion }
}