From b1fa21504bcd9fd658ab37abb6f9dd272dc893e5 Mon Sep 17 00:00:00 2001 From: ppfeiffer Date: Mon, 16 Feb 2026 20:45:30 +0100 Subject: [PATCH] feat: v0.5.4 - Admin-Benutzerverwaltung mit CRUD, Passwort-Reset und Info-Mail Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 13 +++ config.yaml | 2 +- meshbot/auth.py | 113 +++++++++++++++++++-- static/admin.html | 126 +++++++++++++++++++++++- static/js/admin.js | 241 ++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 472 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e85fa4..f7e9f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.5.4] - 2026-02-16 +### Added +- Admin: Benutzer direkt anlegen mit Passwort und Rollenwahl +- Admin: Benutzer bearbeiten (Name, E-Mail, Rolle) per Modal +- Admin: Passwort zuruecksetzen mit optionalem E-Mail-Versand +- Admin: Info-Mail mit Zugangsdaten an Benutzer senden +- Passwort-Generator (crypto.getRandomValues) in Admin-UI +- Verifikationslink wird immer im Log ausgegeben (nicht nur ohne SMTP) + +### Changed +- Admin-Seite komplett ueberarbeitet mit Modals fuer alle Aktionen +- E-Mail-Funktionen um `send_user_info_email` erweitert + ## [0.5.3] - 2026-02-16 ### Changed - Zugangsdaten (AUTH_SECRET_KEY, SMTP-*) aus config.yaml in .env-Datei ausgelagert diff --git a/config.yaml b/config.yaml index 22df586..f469dca 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -version: "0.5.3" +version: "0.5.4" bot: name: "MeshDD-Bot" diff --git a/meshbot/auth.py b/meshbot/auth.py index 9e9ac30..e55a985 100644 --- a/meshbot/auth.py +++ b/meshbot/auth.py @@ -126,6 +126,7 @@ async def _send_email(db, recipient: str, subject: str, html_body: str): async def send_verification_email(db, email: str, token: str): app_url = config.env("SMTP_APP_URL", "http://localhost:8080") verify_url = f"{app_url}/auth/verify?token={token}" + logger.info("Verification link for %s: %s", email, verify_url) subject = "MeshDD-Bot - E-Mail verifizieren" html_body = f"""

MeshDD-Bot Registrierung

@@ -133,16 +134,14 @@ async def send_verification_email(db, email: str, token: str):

{verify_url}

Der Link ist 24 Stunden gueltig.

""" - - if not config.env("SMTP_HOST"): - logger.info("SMTP not configured - verification link: %s", verify_url) - await _send_email(db, email, subject, html_body) + return verify_url async def send_reset_email(db, email: str, token: str): app_url = config.env("SMTP_APP_URL", "http://localhost:8080") reset_url = f"{app_url}/auth/reset-password?token={token}" + logger.info("Reset link for %s: %s", email, reset_url) subject = "MeshDD-Bot - Passwort zuruecksetzen" html_body = f"""

Passwort zuruecksetzen

@@ -150,10 +149,24 @@ async def send_reset_email(db, email: str, token: str):

{reset_url}

Der Link ist 24 Stunden gueltig.

""" + await _send_email(db, email, subject, html_body) + return reset_url - if not config.env("SMTP_HOST"): - logger.info("SMTP not configured - reset link: %s", reset_url) +async def send_user_info_email(db, email: str, name: str, password: str = None): + app_url = config.env("SMTP_APP_URL", "http://localhost:8080") + login_url = f"{app_url}/login" + subject = "MeshDD-Bot - Deine Zugangsdaten" + pw_line = f"

Passwort: {password}

" if password else "

Dein Passwort wurde nicht geaendert.

" + html_body = f""" +

MeshDD-Bot Zugangsdaten

+

Hallo {name},

+

hier sind deine Zugangsdaten fuer MeshDD-Bot:

+

E-Mail: {email}

+{pw_line} +

Anmelden unter: {login_url}

+

Bitte aendere dein Passwort nach dem ersten Login.

+""" await _send_email(db, email, subject, html_body) @@ -338,6 +351,90 @@ def setup_auth_routes(app: web.Application, db): users = await db.get_all_users() return web.json_response(users) + async def admin_user_create(request: web.Request) -> web.Response: + require_admin_api(request) + data = await request.json() + name = data.get("name", "").strip() + email = data.get("email", "").strip().lower() + password = data.get("password", "") + role = data.get("role", "user") + + if not name or not email or not password: + return web.json_response({"error": "Name, E-Mail und Passwort erforderlich"}, status=400) + if len(password) < 8: + return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400) + if role not in ("user", "admin"): + return web.json_response({"error": "Ungueltige Rolle"}, status=400) + + existing = await db.get_user_by_email(email) + if existing: + return web.json_response({"error": "E-Mail bereits registriert"}, status=409) + + hashed = hash_password(password) + await db.create_user(email=email, password=hashed, name=name, role=role, is_verified=1) + + # Send info email if requested + if data.get("send_email"): + await send_user_info_email(db, email, name, password) + + users = await db.get_all_users() + return web.json_response(users) + + async def admin_user_update(request: web.Request) -> web.Response: + require_admin_api(request) + user_id = int(request.match_info["id"]) + data = await request.json() + + updates = {} + if "name" in data and data["name"].strip(): + updates["name"] = data["name"].strip() + if "email" in data and data["email"].strip(): + new_email = data["email"].strip().lower() + existing = await db.get_user_by_email(new_email) + if existing and existing["id"] != user_id: + return web.json_response({"error": "E-Mail bereits vergeben"}, status=409) + updates["email"] = new_email + if "role" in data and data["role"] in ("user", "admin"): + updates["role"] = data["role"] + + if not updates: + return web.json_response({"error": "Keine Aenderungen"}, status=400) + + await db.update_user(user_id, **updates) + users = await db.get_all_users() + return web.json_response(users) + + async def admin_user_reset_password(request: web.Request) -> web.Response: + require_admin_api(request) + user_id = int(request.match_info["id"]) + data = await request.json() + password = data.get("password", "") + + if not password or len(password) < 8: + return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400) + + hashed = hash_password(password) + await db.update_user(user_id, password=hashed) + + # Send info email if requested + if data.get("send_email"): + user = await db.get_user_by_id(user_id) + if user: + await send_user_info_email(db, user["email"], user["name"], password) + + users = await db.get_all_users() + return web.json_response(users) + + async def admin_user_send_info(request: web.Request) -> web.Response: + require_admin_api(request) + user_id = int(request.match_info["id"]) + user = await db.get_user_by_id(user_id) + if not user: + return web.json_response({"error": "Benutzer nicht gefunden"}, status=404) + + await send_user_info_email(db, user["email"], user["name"]) + return web.json_response({"ok": True, "message": f"Info-Mail an {user['email']} gesendet"}) + async def admin_user_delete(request: web.Request) -> web.Response: require_admin_api(request) user_id = int(request.match_info["id"]) @@ -360,6 +457,10 @@ def setup_auth_routes(app: web.Application, db): app.router.add_get("/api/auth/me", me_get) app.router.add_get("/api/admin/users", admin_users_get) + app.router.add_post("/api/admin/users", admin_user_create) + app.router.add_put("/api/admin/users/{id}", admin_user_update) app.router.add_post("/api/admin/users/{id}/role", admin_user_role) app.router.add_post("/api/admin/users/{id}/verify", admin_user_verify) + app.router.add_post("/api/admin/users/{id}/reset-password", admin_user_reset_password) + app.router.add_post("/api/admin/users/{id}/send-info", admin_user_send_info) app.router.add_delete("/api/admin/users/{id}", admin_user_delete) diff --git a/static/admin.html b/static/admin.html index 45c765f..c770654 100644 --- a/static/admin.html +++ b/static/admin.html @@ -58,7 +58,15 @@
-
Benutzerverwaltung
+
+
Benutzerverwaltung
+ +
+ + +
@@ -81,6 +89,122 @@
+ + + + + + + + + diff --git a/static/js/admin.js b/static/js/admin.js index 43f4c50..92ed0f1 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -9,6 +9,7 @@ fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => { if (!u || u.role !== 'admin') { document.getElementById('usersTable').innerHTML = 'Zugriff verweigert'; + document.getElementById('btnShowCreate').classList.add('d-none'); return; } loadUsers(); @@ -32,6 +33,38 @@ function updateSidebar() { }); } +// ── Toast ──────────────────────────────────────────── + +function showToast(message, type = 'success') { + const el = document.getElementById('adminToast'); + el.className = `alert alert-${type} py-1 small mb-2`; + el.textContent = message; + setTimeout(() => el.classList.add('d-none'), 4000); +} + +function showModalAlert(id, message, type = 'danger') { + const el = document.getElementById(id); + el.className = `alert alert-${type} py-1 small`; + el.textContent = message; +} + +function hideModalAlert(id) { + document.getElementById(id).classList.add('d-none'); +} + +// ── Password generator ─────────────────────────────── + +function generatePassword(length = 12) { + const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%'; + let pw = ''; + const arr = new Uint8Array(length); + crypto.getRandomValues(arr); + for (let i = 0; i < length; i++) pw += chars[arr[i] % chars.length]; + return pw; +} + +// ── Load & Render ──────────────────────────────────── + async function loadUsers() { try { const resp = await fetch('/api/admin/users'); @@ -61,22 +94,30 @@ function renderUsers() { const isSelf = currentUser && currentUser.id === user.id; let actions = ''; + // Edit button (always) + actions += ``; + // Reset password + actions += ``; + // Send info email + actions += ``; + if (!isSelf) { - const newRole = user.role === 'admin' ? 'user' : 'admin'; - const roleLabel = user.role === 'admin' ? 'User' : 'Admin'; - actions += ``; + // Verify (if not verified) if (!user.is_verified) { actions += ``; } + // Delete actions += ``; - } else { - actions = 'Du'; } return ` @@ -85,26 +126,190 @@ function renderUsers() { ${roleBadge} ${verifiedIcon} ${created} - ${actions} + ${actions} `; }).join(''); } -async function changeRole(id, role) { +// ── Create User ────────────────────────────────────── + +const createModal = new bootstrap.Modal(document.getElementById('createModal')); + +document.getElementById('btnShowCreate').addEventListener('click', () => { + document.getElementById('createName').value = ''; + document.getElementById('createEmail').value = ''; + document.getElementById('createPassword').value = generatePassword(); + document.getElementById('createRole').value = 'user'; + document.getElementById('createSendEmail').checked = true; + hideModalAlert('createAlert'); + createModal.show(); +}); + +document.getElementById('btnGeneratePassword').addEventListener('click', () => { + document.getElementById('createPassword').value = generatePassword(); +}); + +document.getElementById('btnCreateUser').addEventListener('click', async () => { + const name = document.getElementById('createName').value.trim(); + const email = document.getElementById('createEmail').value.trim(); + const password = document.getElementById('createPassword').value; + const role = document.getElementById('createRole').value; + const sendEmail = document.getElementById('createSendEmail').checked; + + if (!name || !email || !password) { + showModalAlert('createAlert', 'Alle Felder ausfuellen'); + return; + } + if (password.length < 8) { + showModalAlert('createAlert', 'Passwort muss mindestens 8 Zeichen lang sein'); + return; + } + try { - const resp = await fetch(`/api/admin/users/${id}/role`, { + const resp = await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role }) + body: JSON.stringify({ name, email, password, role, send_email: sendEmail }) }); - if (resp.ok) { users = await resp.json(); renderUsers(); } - } catch (e) { console.error('Role change failed:', e); } + if (resp.ok) { + users = await resp.json(); + renderUsers(); + createModal.hide(); + showToast(`Benutzer "${name}" erstellt`); + } else { + const data = await resp.json(); + showModalAlert('createAlert', data.error || 'Fehler beim Erstellen'); + } + } catch (e) { + showModalAlert('createAlert', 'Verbindungsfehler'); + } +}); + +// ── Edit User ──────────────────────────────────────── + +const editModal = new bootstrap.Modal(document.getElementById('editModal')); + +function openEditModal(userId) { + const user = users.find(u => u.id === userId); + if (!user) return; + document.getElementById('editUserId').value = userId; + document.getElementById('editName').value = user.name; + document.getElementById('editEmail').value = user.email; + document.getElementById('editRole').value = user.role; + hideModalAlert('editAlert'); + editModal.show(); } +document.getElementById('btnSaveEdit').addEventListener('click', async () => { + const userId = document.getElementById('editUserId').value; + const name = document.getElementById('editName').value.trim(); + const email = document.getElementById('editEmail').value.trim(); + const role = document.getElementById('editRole').value; + + if (!name || !email) { + showModalAlert('editAlert', 'Name und E-Mail erforderlich'); + return; + } + + try { + const resp = await fetch(`/api/admin/users/${userId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, role }) + }); + if (resp.ok) { + users = await resp.json(); + renderUsers(); + editModal.hide(); + showToast('Benutzer aktualisiert'); + } else { + const data = await resp.json(); + showModalAlert('editAlert', data.error || 'Fehler beim Speichern'); + } + } catch (e) { + showModalAlert('editAlert', 'Verbindungsfehler'); + } +}); + +// ── Reset Password ─────────────────────────────────── + +const resetPwModal = new bootstrap.Modal(document.getElementById('resetPwModal')); + +function openResetPwModal(userId) { + const user = users.find(u => u.id === userId); + if (!user) return; + document.getElementById('resetPwUserId').value = userId; + document.getElementById('resetPwUserName').textContent = user.name; + document.getElementById('resetPwPassword').value = generatePassword(); + document.getElementById('resetPwSendEmail').checked = true; + hideModalAlert('resetPwAlert'); + resetPwModal.show(); +} + +document.getElementById('btnGenerateResetPw').addEventListener('click', () => { + document.getElementById('resetPwPassword').value = generatePassword(); +}); + +document.getElementById('btnResetPassword').addEventListener('click', async () => { + const userId = document.getElementById('resetPwUserId').value; + const password = document.getElementById('resetPwPassword').value; + const sendEmail = document.getElementById('resetPwSendEmail').checked; + + if (!password || password.length < 8) { + showModalAlert('resetPwAlert', 'Passwort muss mindestens 8 Zeichen lang sein'); + return; + } + + try { + const resp = await fetch(`/api/admin/users/${userId}/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password, send_email: sendEmail }) + }); + if (resp.ok) { + users = await resp.json(); + renderUsers(); + resetPwModal.hide(); + showToast('Passwort zurueckgesetzt'); + } else { + const data = await resp.json(); + showModalAlert('resetPwAlert', data.error || 'Fehler'); + } + } catch (e) { + showModalAlert('resetPwAlert', 'Verbindungsfehler'); + } +}); + +// ── Send Info Email ────────────────────────────────── + +async function sendInfoEmail(userId) { + const user = users.find(u => u.id === userId); + if (!user) return; + if (!confirm(`Info-Mail an "${user.name}" (${user.email}) senden?`)) return; + + try { + const resp = await fetch(`/api/admin/users/${userId}/send-info`, { method: 'POST' }); + const data = await resp.json(); + if (resp.ok) { + showToast(data.message || 'Info-Mail gesendet'); + } else { + showToast(data.error || 'Fehler beim Senden', 'danger'); + } + } catch (e) { + showToast('Verbindungsfehler', 'danger'); + } +} + +// ── Verify / Delete ────────────────────────────────── + async function verifyUser(id) { try { const resp = await fetch(`/api/admin/users/${id}/verify`, { method: 'POST' }); - if (resp.ok) { users = await resp.json(); renderUsers(); } + if (resp.ok) { + users = await resp.json(); + renderUsers(); + showToast('Benutzer verifiziert'); + } } catch (e) { console.error('Verify failed:', e); } } @@ -112,10 +317,16 @@ async function deleteUser(id, name) { if (!confirm(`Benutzer "${name}" wirklich loeschen?`)) return; try { const resp = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); - if (resp.ok) { users = await resp.json(); renderUsers(); } + if (resp.ok) { + users = await resp.json(); + renderUsers(); + showToast('Benutzer geloescht'); + } } catch (e) { console.error('Delete failed:', e); } } +// ── Helpers ────────────────────────────────────────── + function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div');