From 861b74f70e117076b02348f176dc3fe34198692b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:12:32 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P2:=20Passwort=20=C3=A4nd?= =?UTF-8?q?ern=20als=20Etage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansicht-passwort.js | 112 +++++++++++++++++++++++++++++++-- test/knAnsichtPasswort.test.js | 48 ++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 test/knAnsichtPasswort.test.js diff --git a/public/neu/ansicht-passwort.js b/public/neu/ansicht-passwort.js index 86e5909..253501a 100644 --- a/public/neu/ansicht-passwort.js +++ b/public/neu/ansicht-passwort.js @@ -1,7 +1,111 @@ -/* Kundenansicht neu: Platzhalter, den das nächste Paket füllt (Stub aus P1). */ +/* Kundenansicht neu (P2): Passwort ändern (Etage #passwort, aus dem Konto-Menü). Dieselben Regeln prüft der Server + (server/services/customerPassword.js); danach sind andere Geräte abgemeldet. Regeln und Texte aus app.js (passwordProblem, initPasswordView). */ -import { h } from './dom.js' +import { api } from './api.js' +import { h, leeren } from './dom.js' +import { K, zuAnsicht, navigiere } from './ansicht-hilfen.js' -export async function zeigePasswort(wurzel, { route, me, nav }) { - wurzel.replaceChildren(h('p', { class: 'kn-leer' }, 'Passwort ändern (folgt)')) +const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus kann das Passwort nicht geändert werden.' +const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs +let beobachter = null // räumt die Felder, wenn die Seite verlassen wird + +/** → [feldId, text] oder null. Reihenfolge und Texte aus app.js passwordProblem. */ +export function passwortProblem(aktuell, neu, wiederholung, email) { + if (!aktuell) return ['pw-current', 'Bitte gib dein aktuelles Passwort ein.'] + if (!neu) return ['pw-new', 'Bitte gib ein neues Passwort ein.'] + if ([...neu].length < 8) return ['pw-new', 'Das neue Passwort muss mindestens 8 Zeichen haben.'] + if (new TextEncoder().encode(neu).length > 256) return ['pw-new', 'Das neue Passwort ist zu lang (höchstens 256 Zeichen).'] + if (!neu.trim()) return ['pw-new', 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.'] + if (neu === aktuell) return ['pw-new', 'Das neue Passwort muss sich vom aktuellen unterscheiden.'] + if (email && neu.trim().toLowerCase() === email.trim().toLowerCase()) return ['pw-new', 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.'] + if (neu !== wiederholung) return ['pw-repeat', 'Die beiden neuen Passwörter stimmen nicht überein.'] + return null +} + +export async function zeigePasswort(wurzel, { route, me, nav } = {}) { + const nr = (laeufe.get(wurzel) ?? 0) + 1 + laeufe.set(wurzel, nr) + beobachter?.disconnect?.() + beobachter = null + const viewAs = me?.viewAs === true + const email = me?.customer?.email || '' + + const benutzername = h('input', { type: 'text', id: 'pw-username', name: 'username', autocomplete: 'username', hidden: true, value: email }) + const aktuell = h('input', { type: 'password', id: 'pw-current', class: K.input, name: 'current-password', autocomplete: 'current-password', required: true, enterkeyhint: 'next' }) + const neu = h('input', { type: 'password', id: 'pw-new', class: K.input, name: 'new-password', autocomplete: 'new-password', minlength: 8, required: true, 'aria-describedby': 'pw-rule', enterkeyhint: 'next' }) + const wdh = h('input', { type: 'password', id: 'pw-repeat', class: K.input, name: 'new-password-repeat', autocomplete: 'new-password', required: true, enterkeyhint: 'done' }) + const zeigen = h('input', { type: 'checkbox', id: 'pw-show' }) + const fehler = h('p', { class: K.fehler, id: 'pw-error', role: 'alert', hidden: true }) + const senden = h('button', { type: 'submit', class: K.primaer, id: 'pw-submit' }, 'Passwort ändern') + const felder = [aktuell, neu, wdh] + const feldPaar = (label, steuerung, ...nach) => h('div', { class: K.feld }, h('label', { for: steuerung.getAttribute('id') }, label), steuerung, ...nach) + + const form = h('form', { id: 'pw-form', class: 'kn-pw-form', novalidate: true }, + benutzername, + feldPaar('Aktuelles Passwort', aktuell), + feldPaar('Neues Passwort', neu, h('small', { class: 'kn-feld-hinweis', id: 'pw-rule' }, 'Mindestens 8 Zeichen. Am sichersten ist ein langer Satz, den nur du kennst.')), + feldPaar('Neues Passwort wiederholen', wdh), + h('label', { class: 'kn-check' }, zeigen, ' Passwörter anzeigen'), + fehler, + h('div', { class: 'kn-aktionen' }, senden, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null), + h('p', { class: 'kn-hinweis' }, 'Danach bist du auf deinen anderen Geräten abgemeldet. Aktuelles Passwort vergessen? ', h('a', zuAnsicht('chat'), 'Schreib uns im Chat'), ' – wir setzen es zurück.')) + + const fertig = h('button', { type: 'button', class: K.knopf, id: 'pw-done-btn' }, 'Fertig') + const erfolg = h('div', { class: 'kn-pw-erfolg', id: 'pw-done', role: 'status', tabindex: '-1', hidden: true }, + h('strong', null, 'Dein Passwort wurde geändert.'), + h('p', null, 'Melde dich ab jetzt mit dem neuen Passwort an. Auf deinen anderen Geräten bist du abgemeldet.'), + fertig) + + const leereFelder = () => { + for (const f of felder) { f.value = ''; f.type = 'password' } + zeigen.checked = false + fehler.hidden = true + } + + zeigen.addEventListener('change', () => { for (const f of felder) f.type = zeigen.checked ? 'text' : 'password' }) + fertig.addEventListener('click', () => { + if (nav === 'push') window.history.back() + else navigiere('#') + }) + form.addEventListener('submit', async (ev) => { + ev.preventDefault() + if (viewAs || senden.disabled) return + fehler.hidden = true + const problem = passwortProblem(aktuell.value, neu.value, wdh.value, email) + if (problem) { + fehler.textContent = problem[1] + fehler.hidden = false + felder.find((f) => f.getAttribute('id') === problem[0])?.focus() + return + } + senden.disabled = true + senden.textContent = 'Wird gespeichert…' + try { + await api('/api/auth/password', { method: 'POST', body: { currentPassword: aktuell.value, newPassword: neu.value } }) + leereFelder() + form.hidden = true + erfolg.hidden = false + erfolg.focus() + } catch (f) { + fehler.textContent = f.message + fehler.hidden = false + if (f.code === 'wrong_password' || f.code === 'current_missing') { aktuell.focus(); aktuell.select?.() } else if (f.code === 'new_invalid') neu.focus() + } finally { + senden.disabled = false + senden.textContent = 'Passwort ändern' + } + }) + + leeren(wurzel).append(h('div', { class: 'kn-panel kn-pw' }, form, erfolg)) + if (viewAs) for (const e of wurzel.querySelectorAll('input, button')) e.disabled = true + + // Wer die Seite verlässt, findet beim nächsten Mal ein leeres Formular (keine Passwörter im DOM) + if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) { + beobachter = new MutationObserver(() => { + // nur räumen, wenn noch etwas darin steht: das Räumen ändert selbst `hidden` und darf den Beobachter nicht endlos wecken + const benutzt = felder.some((f) => f.value) || !erfolg.hidden || !fehler.hidden || form.hidden + if (laeufe.get(wurzel) === nr && benutzt && wurzel.closest('[hidden]')) { leereFelder(); form.hidden = false; erfolg.hidden = true } + }) + beobachter.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['hidden'] }) + } } diff --git a/test/knAnsichtPasswort.test.js b/test/knAnsichtPasswort.test.js new file mode 100644 index 0000000..66d632a --- /dev/null +++ b/test/knAnsichtPasswort.test.js @@ -0,0 +1,48 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { zeigePasswort, passwortProblem } from '../public/neu/ansicht-passwort.js' +import { mit, me, neueWurzel } from './support/knDaten.js' + +test('passwortProblem: Texte wörtlich, Reihenfolge der Prüfungen', () => { + const p = passwortProblem + assert.deepEqual(p('', 'x', 'x', ''), ['pw-current', 'Bitte gib dein aktuelles Passwort ein.']) + assert.deepEqual(p('a', '', '', ''), ['pw-new', 'Bitte gib ein neues Passwort ein.']) + assert.deepEqual(p('a', 'kurz', 'kurz', ''), ['pw-new', 'Das neue Passwort muss mindestens 8 Zeichen haben.']) + assert.deepEqual(p('a', ' ', ' ', ''), ['pw-new', 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.']) + assert.deepEqual(p('abcdefgh', 'abcdefgh', 'abcdefgh', ''), ['pw-new', 'Das neue Passwort muss sich vom aktuellen unterscheiden.']) + assert.deepEqual(p('a', 'Anna@Example.com', 'Anna@Example.com', 'anna@example.com'), ['pw-new', 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.']) + assert.deepEqual(p('a', 'neues-passwort', 'anderes-passwort', ''), ['pw-repeat', 'Die beiden neuen Passwörter stimmen nicht überein.']) + assert.equal(p('a', 'neues-passwort', 'neues-passwort', ''), null) + assert.deepEqual(p('a', 'x'.repeat(257), 'x'.repeat(257), ''), ['pw-new', 'Das neue Passwort ist zu lang (höchstens 256 Zeichen).']) +}) +const felder = (fx, w) => ({ alt: fx.finde(w, 'input[name="current-password"]'), neu: fx.finde(w, 'input[name="new-password"]'), wdh: fx.finde(w, 'input[name="new-password-repeat"]') }) +const zeigeNeu = async (fx, m = me(), nav = 'push') => { const w = neueWurzel(fx); await zeigePasswort(w, { route: { bereich: 'website', etage: 'passwort', query: '' }, me: m, nav }); return w } +test('Passwort ändern: Problem zeigt Text ohne Aufruf, Erfolg sendet und räumt auf, Fertig geht zurück', mit({ 'POST /api/auth/password': { success: true } }, async (fx) => { + let zurueck = 0; fx.window.history.back = () => { zurueck += 1 } + const w = await zeigeNeu(fx); const f = felder(fx, w); assert.equal(fx.finde(w, 'input[name="username"]').value, 'anna@example.com') + assert.equal(fx.finde(w, 'a[data-view="chat"]').textContent, 'Schreib uns im Chat') + await fx.klick(fx.finde(w, 'button[type="submit"]')); assert.match(fx.text(w), /Bitte gib dein aktuelles Passwort ein\./); assert.equal(fx.fetchLog.length, 0) + await fx.setze(f.alt, 'altes-passwort'); await fx.setze(f.neu, 'neues-passwort'); await fx.setze(f.wdh, 'neues-passwort'); await fx.klick(fx.finde(w, 'button[type="submit"]')); await fx.ruhe() + assert.deepEqual(fx.fetchLog.at(-1).body, { currentPassword: 'altes-passwort', newPassword: 'neues-passwort' }); assert.equal(f.neu.value, '') + assert.match(fx.text(w), /Dein Passwort wurde geändert\./); await fx.klick(fx.finde(w, (e) => e.textContent === 'Fertig')); assert.equal(zurueck, 1) +})) +test('Falsches Passwort fokussiert das Feld; Ansichtsmodus sperrt alles', mit({ 'POST /api/auth/password': () => ({ status: 400, body: { error: 'Das aktuelle Passwort stimmt nicht.', code: 'wrong_password' } }) }, async (fx) => { + const w = await zeigeNeu(fx); const f = felder(fx, w) + await fx.setze(f.alt, 'x'); await fx.setze(f.neu, 'neues-passwort'); await fx.setze(f.wdh, 'neues-passwort'); await fx.klick(fx.finde(w, 'button[type="submit"]')); await fx.ruhe() + assert.match(fx.text(w), /Das aktuelle Passwort stimmt nicht\./); assert.equal(fx.document.activeElement, f.alt) + const v = await zeigeNeu(fx, me({ viewAs: true })); assert.ok(fx.alle(v, 'input, button').every((e) => e.hasAttribute('disabled'))); assert.match(fx.text(v), /Im Ansichtsmodus kann das Passwort nicht geändert werden\./) +})) +test('Passwort: neues Passwort ungültig fokussiert das neue Feld, Anzeige-Schalter; im Ansichtsmodus kein Aufruf', mit({ 'POST /api/auth/password': () => ({ status: 400, body: { error: 'Zu schwach.', code: 'new_invalid' } }) }, async (fx) => { + const w = await zeigeNeu(fx, me(), 'tab'); const f = felder(fx, w) + await fx.setze(f.alt, 'x'); await fx.setze(f.neu, 'neues-passwort'); await fx.setze(f.wdh, 'neues-passwort'); await fx.klick(fx.finde(w, 'button[type="submit"]')); await fx.ruhe() + assert.match(fx.text(w), /Zu schwach\./); assert.equal(fx.document.activeElement, f.neu); assert.equal(fx.finde(w, 'button[type="submit"]').hasAttribute('disabled'), false) + await fx.klick(fx.finde(w, '#pw-show')); assert.deepEqual([f.alt.type, f.neu.type, f.wdh.type], ['text', 'text', 'text']); await fx.klick(fx.finde(w, '#pw-show')); assert.equal(f.neu.type, 'password') + const v = await zeigeNeu(fx, me({ viewAs: true })); const vf = felder(fx, v); const vorher = fx.fetchLog.length + await fx.setze(vf.alt, 'x'); await fx.klick(fx.finde(v, 'button[type="submit"]')); assert.equal(fx.fetchLog.length, vorher) +})) +test('Fertig ohne eigenen Verlaufseintrag (Einstieg per Link) geht zur Website', mit({ 'POST /api/auth/password': { success: true } }, async (fx) => { + fx.window.history.back = () => assert.fail('history.back darf hier nicht laufen') + const w = await zeigeNeu(fx, me(), 'tab'); const f = felder(fx, w) + await fx.setze(f.alt, 'altes-passwort'); await fx.setze(f.neu, 'neues-passwort'); await fx.setze(f.wdh, 'neues-passwort'); await fx.klick(fx.finde(w, 'button[type="submit"]')); await fx.ruhe() + await fx.klick(fx.finde(w, (e) => e.textContent === 'Fertig')); assert.equal(fx.window.location.hash, '#') +}))