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/public/global.js b/espid2sensor/public/global.js
index 096abf0..21b867a 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() {
@@ -139,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
@@ -202,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, '');
@@ -467,17 +509,86 @@ 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', () => {
+ const email = btn.getAttribute('data-email');
+ 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.';
+ }
+ });
+ });
+ });
+
+ 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));
- userCancelBtn.addEventListener('click', () => clearUserForm(true));
+ if (userCancelBtn) userCancelBtn.addEventListener('click', () => clearUserForm(true));
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/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;
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/routes/auth.js b/espid2sensor/routes/auth.js
index b7b5658..fa42ad3 100644
--- a/espid2sensor/routes/auth.js
+++ b/espid2sensor/routes/auth.js
@@ -7,22 +7,28 @@ 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
})
});
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('/');
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/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
diff --git a/espid2sensor/views/login.pug b/espid2sensor/views/login.pug
index 3e29149..d9c2256 100644
--- a/espid2sensor/views/login.pug
+++ b/espid2sensor/views/login.pug
@@ -17,6 +17,9 @@ 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
+ 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