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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user