From 7b3403d943ac04f76837cb603544bd0136450e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Fri, 7 Aug 2026 06:46:28 +0000 Subject: [PATCH 1/5] espid2sensor: Admin-Bootstrap via ADMIN_EMAIL/ADMIN_PASSWORD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Für extern verwaltete Deployments (Portainer, kein direkter Mongo-Zugriff) wird beim Start automatisch ein Admin-User angelegt, falls die user-Collection leer ist und ADMIN_EMAIL/ADMIN_PASSWORD gesetzt sind. Login-Seite zeigt einen Hinweis, solange noch kein User existiert. Version auf 1.5.0 angehoben. --- espid2sensor/docs/beschreibung.md | 6 ++++++ espid2sensor/package.json | 4 ++-- espid2sensor/routes/auth.js | 8 +++++--- espid2sensor/server.js | 23 ++++++++++++++++++++++- espid2sensor/views/login.pug | 2 ++ 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/espid2sensor/docs/beschreibung.md b/espid2sensor/docs/beschreibung.md index 7b6d784..67ac439 100755 --- a/espid2sensor/docs/beschreibung.md +++ b/espid2sensor/docs/beschreibung.md @@ -1,6 +1,12 @@ # Beschreibung von esp2senor +### Erststart / Bootstrap + +Ist die `user`-Collection in der Mongo-Datenbank leer (z.B. bei einem neuen, extern verwalteten Deployment ohne direkten Datenbankzugriff), legt der Server beim Start automatisch einen Admin-User an — vorausgesetzt, die Umgebungsvariablen `ADMIN_EMAIL` und `ADMIN_PASSWORD` sind gesetzt. Fehlen sie, erscheint auf der Login-Seite ein entsprechender Hinweis und im Log eine Warnung. + +Nach dem ersten Login mit diesen Zugangsdaten sollte das Passwort über den **Profil**-Tab geändert werden. Sobald mindestens ein User existiert, greift der Bootstrap-Mechanismus nicht mehr — weitere User werden wie gewohnt über den **User**-Tab (nur für Admins) angelegt. + ### Aufruf Für die Tests: diff --git a/espid2sensor/package.json b/espid2sensor/package.json index 0879969..07817a5 100644 --- a/espid2sensor/package.json +++ b/espid2sensor/package.json @@ -1,7 +1,7 @@ { "name": "espid2sensor", - "version": "1.4.0", - "date": "2026-08-06", + "version": "1.5.0", + "date": "2026-08-07", "type": "module", "description": "Kleine Webapp ESP-ID <-> Sensornummer, speichern in MongoDB", "main": "server.js", diff --git a/espid2sensor/routes/auth.js b/espid2sensor/routes/auth.js index b7b5658..c998574 100644 --- a/espid2sensor/routes/auth.js +++ b/espid2sensor/routes/auth.js @@ -7,13 +7,15 @@ export function registerAuthRoutes(app) { const errText = 'Falsche Email oder falsches Passwort.' - app.get('/login', (req, res) => { + app.get('/login', async (req, res) => { const version = pkg.version const vdate = pkg.date - res.render('login', { + const noUsers = (await usersCollection.countDocuments()) === 0; + res.render('login', { error: null, version: version, - vdate: vdate + vdate: vdate, + noUsers: noUsers }) }); diff --git a/espid2sensor/server.js b/espid2sensor/server.js index 1ef5b01..cbda172 100644 --- a/espid2sensor/server.js +++ b/espid2sensor/server.js @@ -3,10 +3,11 @@ import session from 'express-session'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; +import bcrypt from 'bcrypt'; import pkg from './package.json' with { type: "json" } dotenv.config(); -import { initMongo } from './db/mongo.js'; +import { initMongo, getCollections } from './db/mongo.js'; import { registerApiRoutes } from './routes/api.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerAddressRoute } from './routes/address.js'; @@ -34,6 +35,26 @@ app.set('view engine', 'pug'); // DB verbinden await initMongo(); +await bootstrapAdmin(); + +// Legt beim allerersten Start (leere user-Collection) einen Admin aus +// ADMIN_EMAIL/ADMIN_PASSWORD an - notwendig für extern verwaltete Deployments +// ohne direkten Mongo-Zugriff (z.B. per Portainer betriebene Server). +async function bootstrapAdmin() { + const { usersCollection } = getCollections(); + const count = await usersCollection.countDocuments(); + if (count > 0) return; + + const email = process.env.ADMIN_EMAIL; + const password = process.env.ADMIN_PASSWORD; + if (!email || !password) { + console.warn('Keine User in der Datenbank und ADMIN_EMAIL/ADMIN_PASSWORD nicht gesetzt — es kann sich niemand einloggen.'); + return; + } + const passwordHash = await bcrypt.hash(password, 10); + await usersCollection.insertOne({ email: email.toLowerCase(), passwordHash, role: 'admin' }); + console.log(`Bootstrap-Admin angelegt: ${email.toLowerCase()}`); +} // Login-Middleware function requireLogin(req, res, next) { diff --git a/espid2sensor/views/login.pug b/espid2sensor/views/login.pug index 3e29149..811b2d9 100644 --- a/espid2sensor/views/login.pug +++ b/espid2sensor/views/login.pug @@ -17,6 +17,8 @@ html(lang="de") input#password(type="password" name="password" required) button(type="submit") Login p.hint Passwort vergessen? Bitte an einen Admin wenden. + if noUsers + p.hint Es ist noch kein User angelegt. Bitte ADMIN_EMAIL und ADMIN_PASSWORD als Umgebungsvariablen setzen und den Container neu starten. #version Version: #{version} vom #{vdate} if error From 278f91ed3c729deca745338aff49d42fcb463df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Fri, 7 Aug 2026 08:03:16 +0000 Subject: [PATCH 2/5] espid2sensor: Bootstrap-Hinweis auf Login-Seite fett hervorheben --- espid2sensor/views/login.pug | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/espid2sensor/views/login.pug b/espid2sensor/views/login.pug index 811b2d9..d9c2256 100644 --- a/espid2sensor/views/login.pug +++ b/espid2sensor/views/login.pug @@ -18,7 +18,8 @@ html(lang="de") button(type="submit") Login p.hint Passwort vergessen? Bitte an einen Admin wenden. if noUsers - p.hint Es ist noch kein User angelegt. Bitte ADMIN_EMAIL und ADMIN_PASSWORD als Umgebungsvariablen setzen und den Container neu starten. + p.hint + strong Es ist noch kein User angelegt. Bitte ADMIN_EMAIL und ADMIN_PASSWORD als Umgebungsvariablen setzen und den Container neu starten. #version Version: #{version} vom #{vdate} if error From 19c6facf7171c04f29ae5b010f3b51b3a1b81ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Fri, 7 Aug 2026 08:03:27 +0000 Subject: [PATCH 3/5] =?UTF-8?q?espid2sensor:=20User-Liste=20mit=20Passwort?= =?UTF-8?q?-Reset=20und=20L=C3=B6schen=20im=20User-Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt das manuelle E-Mail-Eingabefeld für den Passwort-Reset durch eine Liste aller angemeldeten User. Pro User stehen zwei Aktionen zur Verfügung: Passwort zurücksetzen (nutzt die bestehende /api/resetPassword-Route) und Löschen (neue DELETE /api/users/:id-Route, admin-only, verhindert Löschen des eigenen Accounts). Neue GET /api/users-Route liefert Email/Rolle aller User (admin-only). --- espid2sensor/public/global.js | 99 +++++++++++++++++++++++++---------- espid2sensor/routes/api.js | 25 +++++++++ espid2sensor/views/index.pug | 18 +++---- 3 files changed, 104 insertions(+), 38 deletions(-) diff --git a/espid2sensor/public/global.js b/espid2sensor/public/global.js index 096abf0..6a06aac 100644 --- a/espid2sensor/public/global.js +++ b/espid2sensor/public/global.js @@ -34,6 +34,7 @@ document.addEventListener('DOMContentLoaded', () => { const data = await res.json(); if (data.success) { userResult.textContent = 'User erfolgreich angelegt!'; + if (window.loadUsers) window.loadUsers(); } else { userResult.textContent = data.error || 'Fehler beim Anlegen.'; } @@ -77,34 +78,6 @@ document.addEventListener('DOMContentLoaded', () => { }); } - const resetPasswordBtn = document.getElementById('resetPasswordBtn'); - if (resetPasswordBtn) { - resetPasswordBtn.addEventListener('click', async () => { - const email = document.getElementById('resetEmail').value.trim(); - const newPassword = document.getElementById('resetNewPassword').value; - const resetPasswordResult = document.getElementById('resetPasswordResult'); - if (!email || !newPassword) { - resetPasswordResult.textContent = 'Email und neues Passwort erforderlich.'; - return; - } - try { - const res = await fetch('/api/resetPassword', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, newPassword }) - }); - const data = await res.json(); - if (data.success) { - resetPasswordResult.textContent = 'Passwort erfolgreich zurückgesetzt!'; - document.getElementById('resetPasswordForm').reset(); - } else { - resetPasswordResult.textContent = data.error || 'Fehler beim Zurücksetzen.'; - } - } catch (err) { - resetPasswordResult.textContent = 'Serverfehler.'; - } - }); - } }); function updateSortArrows() { @@ -467,6 +440,73 @@ function showModal(message, showCancelButton, callback) { }); } + async function loadUsers() { + const usersTableBody = document.querySelector('#usersTable tbody'); + const usersResult = document.getElementById('usersResult'); + if (!usersTableBody) return; + try { + const res = await fetch('/api/users'); + const users = await res.json(); + usersTableBody.innerHTML = ''; + users.forEach(u => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${u.email} + ${u.role} + +
+ + +
+ + `; + usersTableBody.appendChild(tr); + }); + + document.querySelectorAll('.resetUserBtn').forEach(btn => { + btn.addEventListener('click', async () => { + const email = btn.getAttribute('data-email'); + const newPassword = prompt(`Neues Passwort für ${email}:`); + if (!newPassword) return; + try { + const res = await fetch('/api/resetPassword', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, newPassword }) + }); + const data = await res.json(); + usersResult.textContent = data.success ? `Passwort für ${email} zurückgesetzt.` : (data.error || 'Fehler beim Zurücksetzen.'); + } catch (err) { + usersResult.textContent = 'Serverfehler.'; + } + }); + }); + + document.querySelectorAll('.deleteUserBtn').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-id'); + showModal('User wirklich löschen?', true, async (confirmed) => { + if (!confirmed) return; + try { + const res = await fetch(`/api/users/${id}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + usersResult.textContent = 'User gelöscht.'; + await loadUsers(); + } else { + usersResult.textContent = data.error || 'Fehler beim Löschen.'; + } + } catch (err) { + usersResult.textContent = 'Serverfehler.'; + } + }); + }); + }); + } catch (err) { + console.error(err); + } + } + saveBtn.addEventListener('click', saveEntry); refreshBtn.addEventListener('click', loadEntries); cancelBtn.addEventListener('click', () => clearForm(true)); @@ -475,9 +515,10 @@ function showModal(message, showCancelButton, callback) { tabInput.addEventListener('click', () => showTab('input')) tabList.addEventListener('click', () => showTab('list')) const tabUser = document.getElementById('tabUser'); - if (tabUser) tabUser.addEventListener('click', () => showTab('user')) + if (tabUser) tabUser.addEventListener('click', () => { showTab('user'); loadUsers(); }) loadEntries(); + window.loadUsers = loadUsers; }); window.showTab = showTab; \ No newline at end of file diff --git a/espid2sensor/routes/api.js b/espid2sensor/routes/api.js index 2f2af8c..96f5bf8 100644 --- a/espid2sensor/routes/api.js +++ b/espid2sensor/routes/api.js @@ -127,4 +127,29 @@ export function registerApiRoutes(app, requireLogin) { res.status(500).json({ error: 'Fehler beim Zurücksetzen' }); } }); + + app.get('/api/users', requireLogin, async (req, res) => { + if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' }); + try { + const users = await usersCollection.find({}, { projection: { email: 1, role: 1 } }).sort({ email: 1 }).toArray(); + res.json(users); + } catch (err) { + res.status(500).json({ error: 'Fehler beim Laden der User' }); + } + }); + + app.delete('/api/users/:id', requireLogin, async (req, res) => { + if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' }); + const { id } = req.params; + if (id === String(req.session.userId)) { + return res.status(400).json({ error: 'Der eigene Account kann nicht gelöscht werden' }); + } + try { + const result = await usersCollection.deleteOne({ _id: new ObjectId(id) }); + if (result.deletedCount === 0) return res.status(404).json({ error: 'User nicht gefunden' }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Fehler beim Löschen' }); + } + }); } diff --git a/espid2sensor/views/index.pug b/espid2sensor/views/index.pug index 4c76f61..b628807 100644 --- a/espid2sensor/views/index.pug +++ b/espid2sensor/views/index.pug @@ -103,15 +103,15 @@ html(lang="de") button#userCancelBtn(type="button") Abbrechen div#userResult div.card - h2 Passwort eines Users zurücksetzen - form#resetPasswordForm - label(for="resetEmail") Email: - input#resetEmail(type="email" required) - label(for="resetNewPassword") Neues Passwort: - input#resetNewPassword(type="password" required) - .twobuttons - button#resetPasswordBtn(type="button") Zurücksetzen - div#resetPasswordResult + h2 Angemeldete User + table#usersTable + thead + tr + th Email + th Rolle + th Aktionen + tbody + div#usersResult #version Version: #{version} vom #{vdate} script(type="module" src="/global.js") \ No newline at end of file From ddc503cb634e625170420a2f52f8ea24cc5947e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Fri, 7 Aug 2026 08:20:11 +0000 Subject: [PATCH 4/5] espid2sensor: Passwort-Reset-Modal statt Browser-prompt(), Absturz bei Nicht-Admin-Login behoben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ersetzt window.prompt() für den Passwort-Reset in der User-Liste durch ein eigenes, zum bestehenden Bestätigungs-Modal passendes Popup (showInputModal), analog zum vorhandenen showModal für Löschbestätigungen. - Behebt ReferenceError beim Login als Nicht-Admin: userCancelBtn wurde als impliziter globaler Bezeichner referenziert statt über document.getElementById, existiert aber nur im admin-only User-Tab-HTML. Für Nicht-Admins riss das den kompletten Setup-Block (Tabs, Liste laden) ab. --- espid2sensor/public/global.js | 100 ++++++++++++++++++++++++++++----- espid2sensor/public/styles.css | 6 ++ 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/espid2sensor/public/global.js b/espid2sensor/public/global.js index 6a06aac..21b867a 100644 --- a/espid2sensor/public/global.js +++ b/espid2sensor/public/global.js @@ -112,6 +112,8 @@ document.addEventListener('DOMContentLoaded', () => { const tableBody = document.querySelector('#entriesTable tbody'); const tabInput = document.getElementById('tabInput'); const tabList = document.getElementById('tabList'); + const cancelBtn = document.getElementById('cancelBtn'); + const userCancelBtn = document.getElementById('userCancelBtn'); // Modal für Fehleranzeige @@ -175,6 +177,73 @@ function showModal(message, showCancelButton, callback) { } }; } + + // Modal für Passwort-Eingabe (z.B. Passwort-Reset aus der User-Liste) + function showInputModal(message, callback) { + document.querySelectorAll('.custom-modal-popup').forEach(m => m.remove()); + + let modal = document.createElement('div'); + modal.className = 'custom-modal-popup'; + + let box = document.createElement('div'); + box.className = 'custom-modal-box'; + + let msg = document.createElement('div'); + msg.className = 'custom-modal-msg'; + msg.textContent = message; + box.appendChild(msg); + + let input = document.createElement('input'); + input.type = 'password'; + input.className = 'custom-modal-input'; + box.appendChild(input); + + let btndiv = document.createElement('div'); + btndiv.className = 'twobuttons'; + + const close = () => { + if (modal.parentNode) modal.parentNode.removeChild(modal); + }; + + let btnCancel = document.createElement('button'); + btnCancel.className = 'custom-modal-btn'; + btnCancel.textContent = 'Abbruch'; + btnCancel.onclick = () => { + close(); + callback(null); + }; + btndiv.appendChild(btnCancel); + + let btnOk = document.createElement('button'); + btnOk.className = 'custom-modal-btn'; + btnOk.textContent = 'OK'; + btnOk.onclick = () => { + const value = input.value; + close(); + callback(value || null); + }; + btndiv.appendChild(btnOk); + + box.appendChild(btndiv); + modal.appendChild(box); + document.body.appendChild(modal); + input.focus(); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + btnOk.click(); + } + }); + + modal.onclick = (e) => { + if (e.target === modal) { + close(); + callback(null); + } + }; + } + // Sensornummer nur Zahlen erlauben sensorNumberInput.addEventListener('input', () => { sensorNumberInput.value = sensorNumberInput.value.replace(/\D/g, ''); @@ -464,21 +533,22 @@ function showModal(message, showCancelButton, callback) { }); document.querySelectorAll('.resetUserBtn').forEach(btn => { - btn.addEventListener('click', async () => { + btn.addEventListener('click', () => { const email = btn.getAttribute('data-email'); - const newPassword = prompt(`Neues Passwort für ${email}:`); - if (!newPassword) return; - try { - const res = await fetch('/api/resetPassword', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, newPassword }) - }); - const data = await res.json(); - usersResult.textContent = data.success ? `Passwort für ${email} zurückgesetzt.` : (data.error || 'Fehler beim Zurücksetzen.'); - } catch (err) { - usersResult.textContent = 'Serverfehler.'; - } + showInputModal(`Neues Passwort für ${email}:`, async (newPassword) => { + if (!newPassword) return; + try { + const res = await fetch('/api/resetPassword', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, newPassword }) + }); + const data = await res.json(); + usersResult.textContent = data.success ? `Passwort für ${email} zurückgesetzt.` : (data.error || 'Fehler beim Zurücksetzen.'); + } catch (err) { + usersResult.textContent = 'Serverfehler.'; + } + }); }); }); @@ -510,7 +580,7 @@ function showModal(message, showCancelButton, callback) { saveBtn.addEventListener('click', saveEntry); refreshBtn.addEventListener('click', loadEntries); cancelBtn.addEventListener('click', () => clearForm(true)); - userCancelBtn.addEventListener('click', () => clearUserForm(true)); + if (userCancelBtn) userCancelBtn.addEventListener('click', () => clearUserForm(true)); tabInput.addEventListener('click', () => showTab('input')) tabList.addEventListener('click', () => showTab('list')) diff --git a/espid2sensor/public/styles.css b/espid2sensor/public/styles.css index afb885f..79c0967 100644 --- a/espid2sensor/public/styles.css +++ b/espid2sensor/public/styles.css @@ -56,6 +56,12 @@ color: red; } +.custom-modal-input { + display: block; + width: 80%; + margin: 0 auto 1.5rem auto; +} + .custom-modal-btn { padding: 0.8rem 2.5rem; font-size: 1.1rem; From 0c45cd9f4608b209baa0fb6e64a5e1cc78fd7fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Fri, 7 Aug 2026 08:20:19 +0000 Subject: [PATCH 5/5] espid2sensor: Version/Datum fehlten auf Login-Seite bei falschem Passwort POST /login rendert bei Fehlermeldung wieder version/vdate/noUsers, so wie GET /login das schon tut - vorher fielen diese Locals beim Fehlerfall weg und der Footer war leer. --- espid2sensor/routes/auth.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/espid2sensor/routes/auth.js b/espid2sensor/routes/auth.js index c998574..fa42ad3 100644 --- a/espid2sensor/routes/auth.js +++ b/espid2sensor/routes/auth.js @@ -21,10 +21,14 @@ export function registerAuthRoutes(app) { app.post('/login', async (req, res) => { const { email, password } = req.body; + const version = pkg.version + const vdate = pkg.date + const noUsers = (await usersCollection.countDocuments()) === 0; + const renderError = () => res.render('login', { error: errText, version, vdate, noUsers }); const user = await usersCollection.findOne({ email: email.toLowerCase() }); - if (!user) return res.render('login', { error: errText }); + if (!user) return renderError(); const match = await bcrypt.compare(password, user.passwordHash); - if (!match) return res.render('login', { error: errText }); + if (!match) return renderError(); req.session.userId = user._id; req.session.isAdmin = user.role === 'admin'; res.redirect('/');