Kundenbereich: Passwort selbst ändern

Im Konto-Menü (Profilbild) gibt es unter "Konto" jetzt "Passwort
ändern". Die Seite fragt aktuelles Passwort, neues Passwort und
Wiederholung ab (mindestens 8 Zeichen, "Passwörter anzeigen"). Am
Handy ist sie eine Unterseite des Tabs, aus dem man kam; Direktlink
/dashboard.html#passwort. Im Ansichtsmodus ist sie gesperrt.

Server (POST /api/auth/password):
- Das aktuelle Passwort wird über eine kurze Appwrite-Session geprüft,
  die gleich wieder gelöscht wird. Tippfehler lösen nicht die globale
  Login-Sperre aus. Es gibt höchstens 5 Prüfungen je Kunde und Stunde,
  das bleibt unter Appwrites Grenze (10 Anmeldungen je E-Mail und
  Stunde).
- Das neue Passwort wird über die Users-API gesetzt. Fehler von
  Appwrite (zuletzt benutzt, zu häufig, persönliche Daten) kommen als
  verständliche Meldung.
- Die Klartext-Kopie in customers.portalPassword und ein Alt-Eintrag
  in den Notizen werden geleert. Ein selbst gewähltes Passwort wird
  nie im Klartext gespeichert; das Ticketsystem zeigt dann "–".
- Der Kunde bekommt eine Bestätigungsmail (Kopie an die Admins wie
  bei allen Kundenmails).

Andere Geräte werden abgemeldet: Kunden-Sessions merken sich beim
Login den Passwortstand (Appwrite passwordUpdate, pwv).
requireSession und /api/auth/me prüfen ihn, 60 s zwischengespeichert.
Das gilt auch, wenn wir im Ticketsystem ein neues Passwort setzen. Ist
Appwrite nicht erreichbar, bleibt die Session gültig. Sessions von
vor diesem Update haben keinen Stand und laufen normal aus (höchstens
7 Tage).

Login-Seite: Hinweis auf "Passwort ändern" statt "über das
Ticketsystem", dazu "Passwort vergessen?" mit support@webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-27 09:22:37 +00:00
1 parent 63ddbf55cc
commit 81119c19bc
12 files changed
+585 -13

No files matched your search

+2 -1
View File
@@ -6,7 +6,8 @@ APPWRITE_ENDPOINT=https://ticket.webklar.com/v1
APPWRITE_PROJECT_ID=6a1058610003c5a13a05
# Fest: nur woms-database (Ticketsystem). Wird bei abweichendem Wert ignoriert.
# APPWRITE_DATABASE_ID=woms-database
# API-Key Scopes (Appwrite 1.8): databases.read/write, collections.read/write, documents.read/write
# API-Key Scopes (Appwrite 1.8): databases.read/write, collections.read/write, documents.read/write,
# users.read/write (Kundenzugänge anlegen, Passwort ändern, Abmelden anderer Geräte)
APPWRITE_API_KEY=
APPWRITE_COLLECTION_CUSTOMERS=customers
+138 -7
View File
@@ -1,3 +1,6 @@
// Sitzung beendet (Passwort auf einem anderen Gerät geändert): nur einmal zur Anmeldung wechseln
let sessionEndRedirected = false
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
@@ -12,7 +15,13 @@ async function api(path, options = {}) {
: `Fehler ${response.status}`
const err = new Error(data.error || fallback)
err.status = response.status
if (data.code) err.code = data.code
if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds
// Passwort auf einem anderen Gerät geändert: diese Sitzung gilt nicht mehr
if (data.code === 'session_revoked' && document.body.classList.contains('dashboard-page') && !sessionEndRedirected) {
sessionEndRedirected = true
window.location.href = `/login.html?error=${encodeURIComponent(err.message)}`
}
throw err
}
return data
@@ -1272,11 +1281,122 @@ async function loadTakeoverCard({ abo, notice = '' } = {}) {
}))
}
/* -------------------------------------------------------- Passwort ändern --- */
// Seite "Passwort ändern" aus dem Konto-Menü. Dieselben Regeln prüft der Server
// (server/services/customerPassword.js); danach sind andere Geräte abgemeldet.
function passwordProblem(current, next, repeat, email) {
if (!current) return ['pw-current', 'Bitte gib dein aktuelles Passwort ein.']
if (!next) return ['pw-new', 'Bitte gib ein neues Passwort ein.']
if ([...next].length < 8) return ['pw-new', 'Das neue Passwort muss mindestens 8 Zeichen haben.']
if (new TextEncoder().encode(next).length > 256) return ['pw-new', 'Das neue Passwort ist zu lang (höchstens 256 Zeichen).']
if (!next.trim()) return ['pw-new', 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.']
if (next === current) return ['pw-new', 'Das neue Passwort muss sich vom aktuellen unterscheiden.']
if (email && next.trim().toLowerCase() === email.trim().toLowerCase()) {
return ['pw-new', 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.']
}
if (next !== repeat) return ['pw-repeat', 'Die beiden neuen Passwörter stimmen nicht überein.']
return null
}
const passwordState = { email: '', ready: false, viewAs: false }
// Kunde bekannt (/api/auth/me): Zugang für Passwort-Manager, im Ansichtsmodus gesperrt
function setPasswordAccount(customer, viewAs) {
passwordState.email = customer.email || ''
passwordState.viewAs = viewAs
passwordState.ready = true
document.getElementById('pw-username').value = passwordState.email
if (!viewAs) return
const submit = document.getElementById('pw-submit')
document.querySelectorAll('#pw-form input').forEach((f) => { f.disabled = true })
submit.disabled = true
submit.insertAdjacentHTML('afterend', '<span class="abo-note">Im Ansichtsmodus kann das Passwort nicht geändert werden.</span>')
}
// Gleich beim Start: das Formular darf nie ohne Script-Handler abgeschickt werden
function initPasswordView() {
const form = document.getElementById('pw-form')
if (!form) return
const done = document.getElementById('pw-done')
const errorEl = document.getElementById('pw-error')
const submit = document.getElementById('pw-submit')
const show = document.getElementById('pw-show')
const fields = ['pw-current', 'pw-new', 'pw-repeat'].map((id) => document.getElementById(id))
const [current, next, repeat] = fields
let returnTo = 'projects'
const clearFields = () => {
fields.forEach((f) => { f.value = ''; f.type = 'password' })
show.checked = false
hideError(errorEl)
}
const showDone = (on) => {
form.classList.toggle('hidden', on)
done.classList.toggle('hidden', !on)
}
// Wer die Seite verlässt, findet beim nächsten Mal ein leeres Formular (keine Passwörter im DOM)
document.addEventListener('kb:view', (ev) => {
if (ev.detail.name !== 'passwort') {
returnTo = ev.detail.name
clearFields()
showDone(false)
} else if (!passwordState.viewAs && !coarsePointer.matches) {
// Am Desktop gleich lostippen; auf dem Handy nicht ungefragt die Tastatur öffnen
current.focus({ preventScroll: true })
}
})
show.addEventListener('change', () => {
fields.forEach((f) => { f.type = show.checked ? 'text' : 'password' })
})
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (submit.disabled || !passwordState.ready || passwordState.viewAs) return
hideError(errorEl)
const problem = passwordProblem(current.value, next.value, repeat.value, passwordState.email)
if (problem) {
showError(errorEl, problem[1])
document.getElementById(problem[0]).focus()
return
}
submit.disabled = true
submit.textContent = 'Wird gespeichert…'
try {
await api('/api/auth/password', {
method: 'POST',
body: JSON.stringify({ currentPassword: current.value, newPassword: next.value }),
})
clearFields()
showDone(true)
done.focus()
} catch (err) {
showError(errorEl, err.message)
if (err.code === 'wrong_password' || err.code === 'current_missing') {
current.focus()
current.select()
} else if (err.code === 'new_invalid') {
next.focus()
}
} finally {
submit.disabled = false
submit.textContent = 'Passwort ändern'
}
})
// Fertig: zurück, woher man kam (App-Ansicht über den Verlauf wie der Zurück-Pfeil)
document.getElementById('pw-done-btn')?.addEventListener('click', () => {
if (window.history.state?.kbSub === 'passwort') window.history.back()
else showView(APP_PARENT.passwort || returnTo)
})
}
/* ---------------------------------------------------------- Navigation --- */
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift).
// "wartung" gibt es nur in der App-Ansicht (Tab), am Desktop zeigt sie das Wartungs-Abo.
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift', projekt: 'projekt' }
// "passwort" oeffnet das Konto-Menue (Profilbild).
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
const loadedViews = new Set()
@@ -1319,6 +1439,7 @@ async function initDashboardPage() {
const loadError = document.getElementById('load-error')
initAppShell()
initPasswordView()
// Konto-Menü (Profilbild): Abmelden und "Abo verwalten & Rechnungen" gibt es nur dort
document.getElementById('kb-sheet-logout')?.addEventListener('click', async () => {
@@ -1345,7 +1466,10 @@ async function initDashboardPage() {
const me = await api('/api/auth/me')
if (!me.authenticated) {
window.location.href = loginUrl
const target = new URL(loginUrl, window.location.origin)
// Passwort auf einem anderen Gerät geändert: Hinweis auf der Anmeldeseite
if (me.reason === 'password_changed' && me.message) target.searchParams.set('error', me.message)
window.location.href = target.pathname + target.search
return
}
if (me.role === 'admin' && !me.customer) {
@@ -1361,6 +1485,7 @@ async function initDashboardPage() {
chatState.viewAs = Boolean(me.viewAs)
takeoverState.customer = customer
renderViewAsBanner(me)
setPasswordAccount(customer, chatState.viewAs)
const displayName = customer.companyName || customer.name || customer.email
document.getElementById('customer-name').textContent = displayName
@@ -1424,9 +1549,10 @@ async function initDashboardPage() {
// Gleiche Bedingung wie der Abschnitt "App-Ansicht" in kundenbereich.css (auch Handys quer)
const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (max-height: 500px)')
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil)
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { abo: 'wartung', takeover: 'wartung', projekt: 'projects' }
const APP_PARENT_LABEL = { wartung: 'Wartung', projects: 'Projekte' }
const APP_PARENT_LABEL = { wartung: 'Wartung', projects: 'Projekte', activity: 'Fortschritt', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
@@ -1498,15 +1624,18 @@ function onAppViewShown(name) {
return
}
const prev = appShell.view
// "Passwort ändern" (Konto-Menü) ist in der App-Ansicht eine Unterseite der Ansicht, in der man gerade war
if (name === 'passwort' && prev !== 'passwort') APP_PARENT.passwort = isAppLayout() ? prev : undefined
const parent = APP_PARENT[name]
const nav = name === prev ? 'same' : parent === prev ? 'push' : APP_PARENT[prev] === name ? 'back' : 'tab'
appShell.view = name
document.body.dataset.kbView = name
document.body.dataset.kbNav = nav
// Unterseiten markieren ihren Tab
// Unterseiten markieren ihren Tab (Passwort aus einer Unterseite: deren Tab)
const tab = APP_PARENT[parent] || parent || name
document.querySelectorAll('.kb-nav-item').forEach((b) => {
const on = b.dataset.view === (parent || name)
const on = b.dataset.view === tab
b.classList.toggle('active', on)
if (on) b.setAttribute('aria-current', 'page')
else b.removeAttribute('aria-current')
@@ -1654,7 +1783,9 @@ function initAccountSheet() {
sheet.addEventListener('close', () => {
sheet.classList.remove('is-closing')
document.documentElement.classList.remove('kb-sheet-open')
avatar.focus({ preventScroll: true })
// Fokus zurück aufs Profilbild – außer die geöffnete Ansicht hat ihn schon gesetzt ("Passwort ändern")
const active = document.activeElement
if (!active || active === document.body || sheet.contains(active)) avatar.focus({ preventScroll: true })
})
// Klick auf den Hintergrund, das X oder eine Ansicht (z. B. "Nachricht schreiben") schließt
sheet.addEventListener('click', (ev) => {
+46
View File
@@ -77,6 +77,10 @@
<a class="legal-strong" href="/widerrufen.html">Vertrag widerrufen</a>
</nav>
<p class="error hidden" id="kb-sheet-error"></p>
<p class="kb-label kb-sheet-label">Konto</p>
<nav class="kb-sheet-links" aria-label="Konto">
<button type="button" data-view="passwort">Passwort ändern</button>
</nav>
<nav class="kb-sheet-links" aria-label="Rechtliches">
<a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a>
@@ -202,6 +206,48 @@
<div id="takeover-card" aria-live="polite"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Passwort ändern (Konto-Menü; app.js initPasswordView). In der App-Ansicht eine Unterseite
des Tabs, aus dem das Menü geöffnet wurde -->
<section class="kb-view" id="view-passwort">
<header class="kb-head">
<h1>Passwort ändern</h1>
<p>Für deine Anmeldung im Kundenbereich.</p>
</header>
<div class="kb-panel pw-panel">
<form id="pw-form" class="pw-form" method="post" action="/api/auth/password" novalidate>
<!-- Für Passwort-Manager: zu welchem Zugang das neue Passwort gehört -->
<input type="text" id="pw-username" name="username" autocomplete="username" hidden>
<div class="tk-field">
<label for="pw-current">Aktuelles Passwort</label>
<input type="password" id="pw-current" name="current-password" autocomplete="current-password" required enterkeyhint="next">
</div>
<div class="tk-field">
<label for="pw-new">Neues Passwort</label>
<input type="password" id="pw-new" name="new-password" autocomplete="new-password" minlength="8" required aria-describedby="pw-rule" enterkeyhint="next">
<small class="pw-rule" id="pw-rule">Mindestens 8 Zeichen. Am sichersten ist ein langer Satz, den nur du kennst.</small>
</div>
<div class="tk-field">
<label for="pw-repeat">Neues Passwort wiederholen</label>
<input type="password" id="pw-repeat" name="new-password-repeat" autocomplete="new-password" required enterkeyhint="done">
</div>
<label class="abo-check pw-show"><input type="checkbox" id="pw-show"> Passwörter anzeigen</label>
<p class="error hidden" id="pw-error" role="alert"></p>
<div class="abo-actions">
<button type="submit" class="abo-btn" id="pw-submit">Passwort ändern</button>
</div>
<p class="kb-note pw-foot">Danach bist du auf deinen anderen Geräten abgemeldet. Aktuelles Passwort vergessen? <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button> – wir setzen es zurück.</p>
</form>
<div class="pw-done hidden" id="pw-done" role="status" tabindex="-1">
<svg class="pw-done-ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.6 2.6L16 9.5"/></svg>
<div>
<strong>Dein Passwort wurde geändert.</strong>
<p>Melde dich ab jetzt mit dem neuen Passwort an. Auf deinen anderen Geräten bist du abgemeldet.</p>
<button type="button" class="kb-btn-outline" id="pw-done-btn">Fertig</button>
</div>
</div>
</div>
</section>
<footer class="kb-legal">
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>
<a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a>
+16
View File
@@ -426,6 +426,22 @@
.tk-question { margin: 10px 0 0; padding: 10px 12px; border-radius: 12px; white-space: pre-wrap; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.tk-reply { margin-top: 10px; display: grid; gap: 8px; }
/* -------------------------------------------------------- Passwort aendern --- */
/* Seite aus dem Konto-Menue (app.js initPasswordView); Felder wie im Gutschrift-Formular */
#view-passwort .kb-panel { max-width: 560px; }
.pw-form { display: grid; gap: 16px; }
.pw-form .tk-field input { font-size: 16px; }
.pw-rule { display: block; margin-top: 6px; font-size: 12.5px; line-height: 1.45; }
.pw-show { margin: -4px 0 0; }
#pw-error { text-align: left; }
.pw-foot { margin: 0; line-height: 1.5; }
.pw-done { display: flex; gap: 14px; align-items: flex-start; outline: none; }
.pw-done-ico {
flex: none; width: 34px; height: 34px; fill: none; stroke: #86efac; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round;
}
.pw-done strong { display: block; margin-top: 5px; font-size: 17px; }
.pw-done p { margin: 6px 0 16px; font-size: 14px; line-height: 1.5; color: var(--kb-muted); }
/* ---------------------------------------------------------- Pflichtlinks --- */
.kb-legal { margin-top: 40px; padding-top: 18px; border-top: 1px solid var(--kb-border); display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; }
.kb-legal a { color: var(--kb-muted); }
+2 -1
View File
@@ -74,7 +74,8 @@
</form>
</div>
<p class="login-hint">
Zugangsdaten erhältst du von Webklar. Passwortänderungen erfolgen über das Ticketsystem.
Zugangsdaten erhältst du von Webklar. Dein Passwort kannst du nach der Anmeldung selbst ändern (Profilbild oben rechts, „Passwort ändern“).
Passwort vergessen? Schreib uns an <a href="mailto:support@webklar.com" style="color:inherit">support@webklar.com</a>.
</p>
<p class="login-hint">
<a href="/kuendigen.html" style="color:#fff;font-weight:700">Verträge hier kündigen</a> ·
+11 -1
View File
@@ -1,5 +1,9 @@
import cookieParser from 'cookie-parser'
import { config } from '../config.js'
import { isSessionPasswordCurrent } from '../services/passwordVersion.js'
import { clearPreviewSession } from '../services/previewSession.js'
export const SESSION_REVOKED_MESSAGE = 'Dein Passwort wurde geändert. Bitte melde dich mit dem neuen Passwort an.'
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
@@ -36,7 +40,7 @@ export function parsePortalSession(req) {
}
}
export function requireSession(req, res, next) {
export async function requireSession(req, res, next) {
const session = parsePortalSession(req)
if (!session) {
return res.status(401).json({ error: 'Nicht angemeldet' })
@@ -44,6 +48,12 @@ export function requireSession(req, res, next) {
if (!session.customerId || !session.appwriteUserId) {
return res.status(401).json({ error: 'Ungültige Session' })
}
// Nach einer Passwortaenderung gelten aeltere Sessions nicht mehr (auf anderen Geraeten abgemeldet)
if (!(await isSessionPasswordCurrent(session))) {
clearPortalSession(res)
clearPreviewSession(res)
return res.status(401).json({ error: SESSION_REVOKED_MESSAGE, code: 'session_revoked' })
}
req.session = session
next()
}
+36 -3
View File
@@ -14,9 +14,13 @@ import {
} from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
import {
SESSION_REVOKED_MESSAGE,
clearPortalSession,
requireSession,
setPortalSession,
} from '../middleware/session.js'
import { changeCustomerPassword } from '../services/customerPassword.js'
import { isSessionPasswordCurrent, rememberPasswordVersion } from '../services/passwordVersion.js'
import {
PREVIEW_COOKIE_NAME,
clearPreviewSession,
@@ -50,8 +54,7 @@ function sanitizeCustomer(customer) {
* Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der
* normale Kunden-Login).
*/
async function resolveStaffAccount(appwriteUserId, email) {
const account = await getUserById(appwriteUserId).catch(() => null)
async function resolveStaffAccount(appwriteUserId, email, account) {
const isAdmin = Array.isArray(account?.labels) && account.labels.includes('admin')
let employee = await getEmployeeByUserId(appwriteUserId).catch(() => null)
@@ -176,9 +179,11 @@ router.post('/login', async (req, res) => {
try {
const user = await loginWithAppwrite(email.trim(), password)
// Appwrite-Konto: Label "admin" und Passwortstand fuer die Kunden-Session (passwordVersion.js)
const account = await getUserById(user.$id).catch(() => null)
// Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht
const staff = await resolveStaffAccount(user.$id, email.trim())
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
if (staff) {
setPortalSession(res, {
role: 'admin',
@@ -212,7 +217,9 @@ router.post('/login', async (req, res) => {
appwriteUserId: user.$id,
name: customer.name || user.name || '',
email: customer.email || user.email || email,
pwv: account?.passwordUpdate || undefined,
})
if (account) rememberPasswordVersion(user.$id, account.passwordUpdate)
const previewToken = await createPreviewSessionToken({
userId: user.$id,
@@ -254,6 +261,25 @@ router.post('/login', async (req, res) => {
}
})
// Passwort aendern (Konto-Menue "Passwort ändern"). Die eigene Session bekommt den neuen
// Passwortstand; alle anderen Sessions des Kunden gelten danach nicht mehr.
router.post('/password', requireSession, async (req, res) => {
if (req.session.role === 'admin') {
return res.status(403).json({ error: 'Im Ansichtsmodus kann das Passwort nicht geändert werden.' })
}
try {
const { passwordVersion } = await changeCustomerPassword(req.session, req.body)
setPortalSession(res, { ...req.session, pwv: passwordVersion || undefined })
return res.json({ success: true })
} catch (err) {
return res.status(err.status || 500).json({
error: err.message || 'Das Passwort konnte nicht geändert werden.',
...(err.code ? { code: err.code } : {}),
...(err.retryAfterSeconds ? { retryAfterSeconds: err.retryAfterSeconds } : {}),
})
}
})
router.post('/logout', (_req, res) => {
clearPortalSession(res)
clearPreviewSession(res)
@@ -389,6 +415,13 @@ router.get('/me', async (req, res) => {
return res.json({ authenticated: false })
}
// Passwort inzwischen geaendert (anderes Geraet oder durch uns): neu anmelden
if (!(await isSessionPasswordCurrent(session))) {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false, reason: 'password_changed', message: SESSION_REVOKED_MESSAGE })
}
let customer = await resolveSessionCustomer(session)
if (!customer) {
clearPortalSession(res)
+20
View File
@@ -94,3 +94,23 @@ export async function loginWithAppwrite(email, password) {
clearLoginCooldown()
return user
}
/**
* Prueft ein Passwort ueber eine Appwrite-Session (Passwort aendern im Kundenbereich). Anders als
* loginWithAppwrite ohne die globale Login-Sperre: tippt ein Kunde sein aktuelles Passwort falsch,
* darf das die Anmeldung der anderen nicht blockieren. Die Session loescht der Aufrufer wieder.
* @returns {Promise<{ userId: string, sessionId: string } | null>} null = Passwort falsch
*/
export async function verifyAppwritePassword(email, password) {
try {
const session = await appwriteFetch('/account/sessions/email', {
method: 'POST',
body: { email, password },
})
return { userId: session?.userId || '', sessionId: session?.$id || '' }
} catch (err) {
// 400: kuerzer als 8 Zeichen (kann nie stimmen), 401: falsche Zugangsdaten; gesperrt u. a. weiterreichen
if (err.status === 400 || (err.status === 401 && err.type === 'user_invalid_credentials')) return null
throw err
}
}
+21
View File
@@ -13,6 +13,7 @@ import {
updateDocument,
Query,
} from './appwriteAdmin.js'
import { forgetPasswordVersion } from './passwordVersion.js'
function readPortalPassword(customer) {
if (customer?.portalPassword) return customer.portalPassword
@@ -192,6 +193,8 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
}
if (normalizedPassword) {
await updateAppwriteUserPassword(appwriteUserId, normalizedPassword)
// Neues Passwort von uns: der Kunde ist auf allen Geraeten abgemeldet (passwordVersion.js)
forgetPasswordVersion(appwriteUserId)
portalPassword = normalizedPassword
}
} else if (normalizedPassword) {
@@ -235,6 +238,24 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
}
}
/**
* Der Kunde hat sein Passwort im Kundenbereich selbst geaendert: die Klartext-Kopie, die das
* Ticketsystem zum Weitergeben anzeigt, stimmt nicht mehr. Sie wird geleert statt ueberschrieben,
* denn ein selbst gewaehltes Passwort gehoert nirgends im Klartext hin. Das Ticketsystem zeigt dann "–".
* @returns {Promise<boolean>} true, wenn eine Kopie entfernt wurde
*/
export async function forgetStoredPortalPassword(customerId) {
const customer = await getDocument(config.collections.customers, customerId)
const legacyInNotes = /^__portalPassword__:/.test(customer.notes || '')
if (!customer.portalPassword && !legacyInNotes) return false
await updateDocument(config.collections.customers, customerId, {
portalPassword: '',
...(legacyInNotes ? { notes: notesWithoutPortalPassword(customer.notes) } : {}),
updatedAt: new Date().toISOString(),
})
return true
}
export async function listCustomersForAdmin() {
// Ohne Limit liefert Appwrite nur 25 Dokumente - neue Kunden fielen dann aus der Liste.
const customers = await listDocuments(config.collections.customers, [
+168
View File
@@ -0,0 +1,168 @@
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 FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
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, 'Ihr Passwort für den WEBklar-Kundenbereich wurde geändert', [
`Guten Tag ${name || ''}`.trim() + ',',
'',
`das Passwort für Ihren Zugang zum WEBklar-Kundenbereich (${email}) wurde am ${fmtDateTime(changedAt)} Uhr geändert.`,
'Auf allen anderen Geräten wurden Sie dabei abgemeldet.',
'',
'Waren Sie das? Dann ist nichts weiter zu tun.',
'Falls nicht, melden Sie sich bitte umgehend bei uns: support@webklar.com',
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
FOOTER,
])
}
/**
* 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 }
}
+63
View File
@@ -0,0 +1,63 @@
import { getUserById } from './appwriteAdmin.js'
/**
* Passwortstand je Appwrite-Konto (users.passwordUpdate, aendert sich nur mit dem Passwort).
* Kunden-Sessions merken sich den Stand beim Login (pwv). Wird das Passwort geaendert - im
* Kundenbereich oder durch uns im Ticketsystem -, gelten aeltere Sessions nicht mehr: der Kunde
* ist auf allen anderen Geraeten abgemeldet. Sessions ohne pwv (vor dieser Pruefung angemeldet)
* laufen normal aus. Kurz zwischengespeichert, damit nicht jede API-Anfrage Appwrite fragt;
* Aenderungen ueber diesen Server gelten sofort (rememberPasswordVersion).
*/
const CACHE_MS = 60 * 1000
const cache = new Map() // appwriteUserId -> { value, at }
let lastWarnAt = 0
function remember(userId, value) {
if (cache.size > 2000) {
const now = Date.now()
for (const [key, entry] of cache) if (now - entry.at > CACHE_MS) cache.delete(key)
}
cache.set(userId, { value, at: Date.now() })
}
export function rememberPasswordVersion(userId, passwordUpdate) {
if (userId) remember(userId, passwordUpdate || '')
}
export function forgetPasswordVersion(userId) {
cache.delete(userId)
}
/** Aktueller Stand; '' = Konto ohne Passwort bzw. geloescht. Wirft bei Appwrite-Fehlern. */
async function currentPasswordVersion(userId) {
const hit = cache.get(userId)
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value
let value
try {
const user = await getUserById(userId)
value = user?.passwordUpdate || ''
} catch (err) {
if (err.status !== 404) throw err
value = ''
}
remember(userId, value)
return value
}
/**
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
*/
export async function isSessionPasswordCurrent(session) {
if (!session?.pwv || session.role === 'admin' || !session.appwriteUserId) return true
try {
return (await currentPasswordVersion(session.appwriteUserId)) === session.pwv
} catch (err) {
if (Date.now() - lastWarnAt > 10 * 60 * 1000) {
lastWarnAt = Date.now()
console.warn('[auth] Passwortstand nicht prüfbar, Session gilt weiter:', err.message)
}
return true
}
}
+62
View File
@@ -0,0 +1,62 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
// Vor dem Import von config.js setzen: Appwrite unerreichbar (Port 9), damit kein Test ins Netz geht
process.env.APPWRITE_ENDPOINT = 'http://127.0.0.1:9/v1'
process.env.APPWRITE_API_KEY = 'test'
const { attemptState, newPasswordProblem, recordAttempt } = await import('../server/services/customerPassword.js')
const { forgetPasswordVersion, isSessionPasswordCurrent, rememberPasswordVersion } = await import('../server/services/passwordVersion.js')
test('Neues Passwort: Laenge in Zeichen, Grenze von Appwrite in Byte', () => {
assert.equal(newPasswordProblem('alt-alt-alt', ''), 'Bitte gib ein neues Passwort ein.')
assert.match(newPasswordProblem('alt-alt-alt', '1234567'), /mindestens 8 Zeichen/)
assert.equal(newPasswordProblem('alt-alt-alt', '1234567ä'), '')
// Emoji zaehlen als ein Zeichen (nicht als zwei UTF-16-Einheiten)
assert.match(newPasswordProblem('alt-alt-alt', '😀'.repeat(7)), /mindestens 8 Zeichen/)
assert.equal(newPasswordProblem('alt-alt-alt', '😀'.repeat(8)), '')
assert.equal(newPasswordProblem('alt-alt-alt', 'x'.repeat(256)), '')
assert.match(newPasswordProblem('alt-alt-alt', 'x'.repeat(257)), /zu lang/)
assert.equal(newPasswordProblem('alt-alt-alt', 'ü'.repeat(128)), '')
assert.match(newPasswordProblem('alt-alt-alt', 'ü'.repeat(129)), /zu lang/)
})
test('Neues Passwort: nicht leer, nicht das alte, nicht die E-Mail', () => {
assert.match(newPasswordProblem('alt-alt-alt', ' '.repeat(10)), /Leerzeichen/)
assert.match(newPasswordProblem('Altes-Passwort', 'Altes-Passwort'), /unterscheiden/)
assert.match(newPasswordProblem('alt-alt-alt', ' Kunde@Beispiel.de ', 'kunde@beispiel.de'), /E-Mail-Adresse/)
assert.equal(newPasswordProblem('alt-alt-alt', 'kunde@beispiel.de!', 'kunde@beispiel.de'), '')
assert.equal(newPasswordProblem('alt-alt-alt', 'Ein langer Satz, den nur ich kenne'), '')
})
test('Versuche: 5 je Stunde und Kunde, danach Sperre bis der erste eine Stunde alt ist', () => {
const t0 = Date.parse('2026-09-27T10:00:00Z')
const min = 60 * 1000
assert.deepEqual(attemptState('k1', t0), { left: 5, blockedFor: 0 })
for (let i = 0; i < 4; i++) recordAttempt('k1', t0 + i * min)
assert.deepEqual(attemptState('k1', t0 + 4 * min), { left: 1, blockedFor: 0 })
recordAttempt('k1', t0 + 4 * min)
assert.deepEqual(attemptState('k1', t0 + 5 * min), { left: 0, blockedFor: 55 * 60 })
// Nach einer Stunde ist der erste Versuch verfallen: wieder einer frei
assert.deepEqual(attemptState('k1', t0 + 60 * min), { left: 1, blockedFor: 0 })
assert.deepEqual(attemptState('k1', t0 + 65 * min), { left: 5, blockedFor: 0 })
// Andere Kunden sind nicht betroffen
assert.deepEqual(attemptState('k2', t0 + 5 * min), { left: 5, blockedFor: 0 })
})
test('Sessions: nach Passwortaenderung ungueltig, alte ohne Stand und Admins bleiben', async () => {
rememberPasswordVersion('u1', '2026-09-27T10:00:00.000+00:00')
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1', pwv: '2026-09-27T10:00:00.000+00:00' }), true)
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1', pwv: '2026-09-01T08:00:00.000+00:00' }), false)
// Session von vor dieser Pruefung (ohne pwv) laeuft normal aus
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1' }), true)
// "Als Kunde ansehen": appwriteUserId ist die des Admins
assert.equal(await isSessionPasswordCurrent({ role: 'admin', viewAs: true, appwriteUserId: 'u1', customerId: 'c1', pwv: 'x' }), true)
// Konto ohne Passwort (geleert): keine Session gilt mehr
rememberPasswordVersion('u2', '')
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u2', customerId: 'c2', pwv: '2026-09-27T10:00:00.000+00:00' }), false)
})
test('Sessions: ist Appwrite nicht erreichbar, bleibt man angemeldet', async () => {
forgetPasswordVersion('u3')
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u3', customerId: 'c3', pwv: 'irgendwas' }), true)
})