diff --git a/.env.example b/.env.example index 1ab02fb..3182c99 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/public/app.js b/public/app.js index 2ac816e..c2f3549 100644 --- a/public/app.js +++ b/public/app.js @@ -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', 'Im Ansichtsmodus kann das Passwort nicht geändert werden.') +} + +// 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) => { diff --git a/public/dashboard.html b/public/dashboard.html index 7db6210..e823884 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -77,6 +77,10 @@ Vertrag widerrufen
Konto
Lade…
Für deine Anmeldung im Kundenbereich.
Danach bist du auf deinen anderen Geräten abgemeldet. Aktuelles Passwort vergessen? Schreib uns im Chat – wir setzen es zurück.
Melde dich ab jetzt mit dem neuen Passwort an. Auf deinen anderen Geräten bist du abgemeldet.