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>
157 lines
7.3 KiB
JavaScript
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 }
|
|
}
|