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:
1 parent
63ddbf55cc
commit
81119c19bc
12 files changed
+585
-13
No files matched your search
+2
-1
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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> ·
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user