Merge branch 'espid2sensor-admin-bootstrap'

Admin-Bootstrap via ADMIN_EMAIL/ADMIN_PASSWORD für extern verwaltete
Deployments, User-Liste mit Passwort-Reset/Löschen im User-Tab, sowie
zwei Bugfixes (Absturz bei Nicht-Admin-Login, fehlende Version bei
Login-Fehler).
This commit is contained in:
2026-08-07 09:43:56 +00:00
9 changed files with 225 additions and 47 deletions
+6
View File
@@ -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:
+2 -2
View File
@@ -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",
+141 -30
View File
@@ -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 = `
<td>${u.email}</td>
<td>${u.role}</td>
<td>
<div class="twobuttons">
<button data-id="${u._id}" data-email="${u.email}" class="editBtn resetUserBtn" title="Passwort zurücksetzen">🔑</button>
<button data-id="${u._id}" class="deleteBtn deleteUserBtn" title="Löschen">🗑️</button>
</div>
</td>
`;
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;
+6
View File
@@ -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;
+25
View File
@@ -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' });
}
});
}
+10 -4
View File
@@ -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
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('/');
+22 -1
View File
@@ -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) {
+9 -9
View File
@@ -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")
+3
View File
@@ -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