feat(auth): Rolle Mitarbeiter + Einladungs-Workflow (closes #7)

- Rollensystem: Public → Mitarbeiter → Admin (Rolle user entfällt)
- DB-Migration: must_change_password-Spalte, user→mitarbeiter
- require_staff_api(): erlaubt mitarbeiter + admin
- POST /api/admin/invite: Einladung mit auto-generiertem Passwort + E-Mail
- POST /auth/change-password: Pflicht-Passwortwechsel
- Login: force_password_change-Redirect
- Sidebar: sidebar-staff für Scheduler/NINA/Einstellungen
- Scheduler/NINA: read-only für Mitarbeiter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-20 22:51:06 +01:00
parent f608f513a8
commit c443a9f26d
13 changed files with 325 additions and 114 deletions

View file

@ -1,5 +1,28 @@
# Changelog # Changelog
## [0.08.26] - 2026-02-20
### Added
- **Rolle Mitarbeiter + Einladungs-Workflow** (closes #7):
- Neues Rollensystem: Public → Mitarbeiter → Admin (Rolle `user` entfällt).
- DB-Migration: `must_change_password`-Spalte, bestehende `user`→`mitarbeiter`.
- `require_staff_api()`: erlaubt `mitarbeiter` + `admin`.
- `POST /api/admin/invite`: Admin lädt Mitarbeiter ein; Passwort auto-generiert
(12 Zeichen), E-Mail mit Login-URL und Passwort-Änderungspflicht.
- `POST /auth/change-password`: Pflicht-Passwortwechsel für eingeladene Accounts.
- Neue Seite `/auth/change-password` (`change-password.html`).
- Login-Redirect: `force_password_change=true``/auth/change-password`.
- Admin-Seite: Button „Mitarbeiter einladen" ersetzt „Neuer Benutzer".
### Changed
- **Sidebar**: `sidebar-staff`-Klasse statt `sidebar-user/admin` für Scheduler,
NINA, Einstellungen (sichtbar für Mitarbeiter + Admin). Benutzer-Link nur Admin.
- **API-Auth**: `/api/send`, `/api/node/config`, `/api/nina/config`,
`/api/nina/alerts`, `GET /api/scheduler/jobs` jetzt `require_staff_api`.
- **Scheduler**: Neue-Job/Bearbeiten/Löschen-Buttons deaktiviert für Mitarbeiter
(Nur Lesezugriff-Badge).
- **NINA**: Alle Eingabefelder und Speichern-Button deaktiviert für Mitarbeiter.
## [0.08.25] - 2026-02-20 ## [0.08.25] - 2026-02-20
### Added ### Added

View file

@ -1,4 +1,4 @@
version: "0.08.25" version: "0.08.26"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"

View file

@ -78,6 +78,13 @@ def require_user_api(request: web.Request):
raise web.HTTPUnauthorized(text="Login required") raise web.HTTPUnauthorized(text="Login required")
def require_staff_api(request: web.Request):
if not request["user"]:
raise web.HTTPUnauthorized(text="Login required")
if request["user"]["role"] not in ("mitarbeiter", "admin"):
raise web.HTTPForbidden(text="Staff access required")
def require_admin_api(request: web.Request): def require_admin_api(request: web.Request):
if not request["user"]: if not request["user"]:
raise web.HTTPUnauthorized(text="Login required") raise web.HTTPUnauthorized(text="Login required")
@ -155,6 +162,23 @@ async def send_reset_email(db, email: str, token: str):
return reset_url return reset_url
async def send_invite_email(db, email: str, name: str, password: str):
app_url = config.get("smtp.app_url", "http://localhost:8081")
login_url = f"{app_url}/login"
logger.info("Invite for %s: login at %s", email, login_url)
subject = "MeshDD-Dashboard - Einladung als Mitarbeiter"
html_body = f"""<html><body>
<h2>Willkommen bei MeshDD-Dashboard!</h2>
<p>Hallo {name},</p>
<p>du wurdest als <strong>Mitarbeiter</strong> eingeladen. Hier sind deine Zugangsdaten:</p>
<p><strong>E-Mail:</strong> {email}</p>
<p><strong>Passwort:</strong> {password}</p>
<p>Anmelden unter: <a href="{login_url}">{login_url}</a></p>
<p><strong>Bitte aendere dein Passwort nach dem ersten Login.</strong></p>
</body></html>"""
await _send_email(db, email, subject, html_body)
async def send_user_info_email(db, email: str, name: str, password: str = None): async def send_user_info_email(db, email: str, name: str, password: str = None):
app_url = config.get("smtp.app_url", "http://localhost:8081") app_url = config.get("smtp.app_url", "http://localhost:8081")
login_url = f"{app_url}/login" login_url = f"{app_url}/login"
@ -201,6 +225,7 @@ def setup_auth_routes(app: web.Application, db):
"email": user["email"], "email": user["email"],
"name": user["name"], "name": user["name"],
"role": user["role"], "role": user["role"],
"force_password_change": bool(user.get("must_change_password", 0)),
}) })
async def register_post(request: web.Request) -> web.Response: async def register_post(request: web.Request) -> web.Response:
@ -340,7 +365,7 @@ def setup_auth_routes(app: web.Application, db):
user_id = int(request.match_info["id"]) user_id = int(request.match_info["id"])
data = await request.json() data = await request.json()
role = data.get("role", "") role = data.get("role", "")
if role not in ("user", "admin"): if role not in ("mitarbeiter", "admin"):
return web.json_response({"error": "Ungueltige Rolle"}, status=400) return web.json_response({"error": "Ungueltige Rolle"}, status=400)
await db.update_user(user_id, role=role) await db.update_user(user_id, role=role)
users = await db.get_all_users() users = await db.get_all_users()
@ -359,13 +384,13 @@ def setup_auth_routes(app: web.Application, db):
name = data.get("name", "").strip() name = data.get("name", "").strip()
email = data.get("email", "").strip().lower() email = data.get("email", "").strip().lower()
password = data.get("password", "") password = data.get("password", "")
role = data.get("role", "user") role = data.get("role", "mitarbeiter")
if not name or not email or not password: if not name or not email or not password:
return web.json_response({"error": "Name, E-Mail und Passwort erforderlich"}, status=400) return web.json_response({"error": "Name, E-Mail und Passwort erforderlich"}, status=400)
if len(password) < 8: if len(password) < 8:
return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400) return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400)
if role not in ("user", "admin"): if role not in ("mitarbeiter", "admin"):
return web.json_response({"error": "Ungueltige Rolle"}, status=400) return web.json_response({"error": "Ungueltige Rolle"}, status=400)
existing = await db.get_user_by_email(email) existing = await db.get_user_by_email(email)
@ -396,7 +421,7 @@ def setup_auth_routes(app: web.Application, db):
if existing and existing["id"] != user_id: if existing and existing["id"] != user_id:
return web.json_response({"error": "E-Mail bereits vergeben"}, status=409) return web.json_response({"error": "E-Mail bereits vergeben"}, status=409)
updates["email"] = new_email updates["email"] = new_email
if "role" in data and data["role"] in ("user", "admin"): if "role" in data and data["role"] in ("mitarbeiter", "admin"):
updates["role"] = data["role"] updates["role"] = data["role"]
if not updates: if not updates:
@ -447,6 +472,53 @@ def setup_auth_routes(app: web.Application, db):
users = await db.get_all_users() users = await db.get_all_users()
return web.json_response(users) return web.json_response(users)
async def admin_invite(request: web.Request) -> web.Response:
require_admin_api(request)
import secrets
import string
data = await request.json()
name = data.get("name", "").strip()
email = data.get("email", "").strip().lower()
if not name or not email:
return web.json_response({"error": "Name und E-Mail erforderlich"}, status=400)
existing = await db.get_user_by_email(email)
if existing:
return web.json_response({"error": "E-Mail bereits registriert"}, status=409)
alphabet = string.ascii_letters + string.digits + "!@#$%"
password = "".join(secrets.choice(alphabet) for _ in range(12))
hashed = hash_password(password)
await db.create_user(
email=email,
password=hashed,
name=name,
role="mitarbeiter",
is_verified=1,
must_change_password=1,
)
await send_invite_email(db, email, name, password)
users = await db.get_all_users()
return web.json_response(users, status=201)
async def change_password_get(request: web.Request) -> web.Response:
import os
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
return web.FileResponse(os.path.join(static_dir, "change-password.html"))
async def change_password_post(request: web.Request) -> web.Response:
user = request["user"]
if not user:
return web.json_response({"error": "Login erforderlich"}, status=401)
data = await request.json()
password = data.get("password", "")
if 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, must_change_password=0)
return web.json_response({"ok": True, "message": "Passwort geaendert"})
# Register routes # Register routes
app.router.add_post("/auth/login", login_post) app.router.add_post("/auth/login", login_post)
app.router.add_post("/auth/register", register_post) app.router.add_post("/auth/register", register_post)
@ -460,9 +532,12 @@ def setup_auth_routes(app: web.Application, db):
app.router.add_get("/api/admin/users", admin_users_get) app.router.add_get("/api/admin/users", admin_users_get)
app.router.add_post("/api/admin/users", admin_user_create) app.router.add_post("/api/admin/users", admin_user_create)
app.router.add_post("/api/admin/invite", admin_invite)
app.router.add_put("/api/admin/users/{id}", admin_user_update) 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}/role", admin_user_role)
app.router.add_post("/api/admin/users/{id}/verify", admin_user_verify) 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}/reset-password", admin_user_reset_password)
app.router.add_post("/api/admin/users/{id}/send-info", admin_user_send_info) 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) app.router.add_delete("/api/admin/users/{id}", admin_user_delete)
app.router.add_get("/auth/change-password", change_password_get)
app.router.add_post("/auth/change-password", change_password_post)

View file

@ -74,8 +74,9 @@ class Database:
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'mitarbeiter',
is_verified INTEGER NOT NULL DEFAULT 0, is_verified INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, created_at REAL NOT NULL,
updated_at REAL NOT NULL updated_at REAL NOT NULL
); );
@ -125,6 +126,19 @@ class Database:
await self.db.commit() await self.db.commit()
logger.info("Migration: added channel column to commands table") logger.info("Migration: added channel column to commands table")
async with self.db.execute("PRAGMA table_info(users)") as c:
cols = {row[1] async for row in c}
if "must_change_password" not in cols:
await self.db.execute(
"ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0"
)
await self.db.commit()
logger.info("Migration: added must_change_password column to users table")
# Migrate legacy role 'user' → 'mitarbeiter'
await self.db.execute("UPDATE users SET role = 'mitarbeiter' WHERE role = 'user'")
await self.db.commit()
# ── Node methods ────────────────────────────────── # ── Node methods ──────────────────────────────────
async def upsert_node(self, node_id: str, **kwargs) -> dict: async def upsert_node(self, node_id: str, **kwargs) -> dict:
@ -239,11 +253,13 @@ class Database:
# ── User methods ────────────────────────────────── # ── User methods ──────────────────────────────────
async def create_user(self, email: str, password: str, name: str, role: str = "user", is_verified: int = 0) -> dict: async def create_user(self, email: str, password: str, name: str, role: str = "mitarbeiter",
is_verified: int = 0, must_change_password: int = 0) -> dict:
now = time.time() now = time.time()
cursor = await self.db.execute( cursor = await self.db.execute(
"INSERT INTO users (email, password, name, role, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO users (email, password, name, role, is_verified, must_change_password, created_at, updated_at) "
(email, password, name, role, is_verified, now, now), "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(email, password, name, role, is_verified, must_change_password, now, now),
) )
await self.db.commit() await self.db.commit()
return await self.get_user_by_id(cursor.lastrowid) return await self.get_user_by_id(cursor.lastrowid)
@ -264,7 +280,8 @@ class Database:
async def get_all_users(self) -> list[dict]: async def get_all_users(self) -> list[dict]:
async with self.db.execute( async with self.db.execute(
"SELECT id, email, name, role, is_verified, created_at, updated_at FROM users ORDER BY created_at DESC" "SELECT id, email, name, role, is_verified, must_change_password, created_at, updated_at "
"FROM users ORDER BY created_at DESC"
) as cursor: ) as cursor:
return [dict(row) async for row in cursor] return [dict(row) async for row in cursor]

View file

@ -7,7 +7,7 @@ from aiohttp import web
from meshbot import config from meshbot import config
from meshbot.database import Database from meshbot.database import Database
from meshbot.auth import setup_session, auth_middleware, setup_auth_routes, require_user_api, require_admin_api from meshbot.auth import setup_session, auth_middleware, setup_auth_routes, require_user_api, require_admin_api, require_staff_api
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -174,7 +174,7 @@ class WebServer:
return web.json_response(links) return web.json_response(links)
async def _api_send(self, request: web.Request) -> web.Response: async def _api_send(self, request: web.Request) -> web.Response:
require_user_api(request) require_staff_api(request)
if not self.bot: if not self.bot:
return web.json_response({"error": "Bot not available"}, status=503) return web.json_response({"error": "Bot not available"}, status=503)
data = await request.json() data = await request.json()
@ -186,7 +186,7 @@ class WebServer:
return web.json_response({"ok": True}) return web.json_response({"ok": True})
async def _api_node_config(self, request: web.Request) -> web.Response: async def _api_node_config(self, request: web.Request) -> web.Response:
require_admin_api(request) require_staff_api(request)
if not self.bot: if not self.bot:
return web.json_response({"error": "Bot not available"}, status=503) return web.json_response({"error": "Bot not available"}, status=503)
try: try:
@ -247,7 +247,7 @@ class WebServer:
return web.json_response({"ok": True}) return web.json_response({"ok": True})
async def _api_nina_get(self, request: web.Request) -> web.Response: async def _api_nina_get(self, request: web.Request) -> web.Response:
require_admin_api(request) require_staff_api(request)
if not self.nina: if not self.nina:
return web.json_response({"error": "NINA not available"}, status=503) return web.json_response({"error": "NINA not available"}, status=503)
return web.json_response(self.nina.get_config()) return web.json_response(self.nina.get_config())
@ -262,12 +262,13 @@ class WebServer:
return web.json_response(cfg) return web.json_response(cfg)
async def _api_nina_alerts(self, request: web.Request) -> web.Response: async def _api_nina_alerts(self, request: web.Request) -> web.Response:
require_admin_api(request) require_staff_api(request)
if not self.nina: if not self.nina:
return web.json_response([]) return web.json_response([])
return web.json_response(self.nina.get_active_alerts()) return web.json_response(self.nina.get_active_alerts())
async def _api_scheduler_get(self, request: web.Request) -> web.Response: async def _api_scheduler_get(self, request: web.Request) -> web.Response:
require_staff_api(request)
if not self.scheduler: if not self.scheduler:
return web.json_response([], status=200) return web.json_response([], status=200)
return web.json_response(self.scheduler.get_jobs()) return web.json_response(self.scheduler.get_jobs())

View file

@ -41,8 +41,8 @@
<main class="content-wrapper"> <main class="content-wrapper">
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-0"><i class="bi bi-people me-1 text-info"></i>Benutzerverwaltung</h6> <h6 class="mb-0"><i class="bi bi-people me-1 text-info"></i>Benutzerverwaltung</h6>
<button class="btn btn-sm btn-info" id="btnShowCreate"> <button class="btn btn-sm btn-info" id="btnShowInvite">
<i class="bi bi-person-plus me-1"></i>Neuer Benutzer <i class="bi bi-person-plus me-1"></i>Mitarbeiter einladen
</button> </button>
</div> </div>
@ -70,48 +70,29 @@
</div> </div>
</main> </main>
<!-- Create User Modal --> <!-- Invite Modal -->
<div class="modal fade" id="createModal" tabindex="-1"> <div class="modal fade" id="inviteModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header py-2"> <div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-person-plus me-1"></i>Neuer Benutzer</h6> <h6 class="modal-title"><i class="bi bi-person-plus me-1"></i>Mitarbeiter einladen</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="createAlert" class="alert py-1 small d-none"></div> <div id="inviteAlert" class="alert py-1 small d-none"></div>
<p class="small text-body-secondary mb-2">Ein initiales Passwort wird automatisch generiert und per E-Mail gesendet. Der Mitarbeiter muss das Passwort beim ersten Login ändern.</p>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small mb-1">Name</label> <label class="form-label small mb-1">Name</label>
<input type="text" class="form-control form-control-sm" id="createName" required> <input type="text" class="form-control form-control-sm" id="inviteName" required>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small mb-1">E-Mail</label> <label class="form-label small mb-1">E-Mail</label>
<input type="email" class="form-control form-control-sm" id="createEmail" required> <input type="email" class="form-control form-control-sm" id="inviteEmail" required>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Passwort (min. 8 Zeichen)</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" id="createPassword" minlength="8" required>
<button class="btn btn-outline-secondary" type="button" id="btnGeneratePassword" title="Generieren">
<i class="bi bi-shuffle"></i>
</button>
</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Rolle</label>
<select class="form-select form-select-sm" id="createRole">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="createSendEmail" checked>
<label class="form-check-label small" for="createSendEmail">Zugangsdaten per E-Mail senden</label>
</div> </div>
</div> </div>
<div class="modal-footer py-1"> <div class="modal-footer py-1">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Abbrechen</button> <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-sm btn-info" id="btnCreateUser">Erstellen</button> <button type="button" class="btn btn-sm btn-info" id="btnSendInvite">Einladen &amp; E-Mail senden</button>
</div> </div>
</div> </div>
</div> </div>
@ -139,7 +120,7 @@
<div class="mb-2"> <div class="mb-2">
<label class="form-label small mb-1">Rolle</label> <label class="form-label small mb-1">Rolle</label>
<select class="form-select form-select-sm" id="editRole"> <select class="form-select form-select-sm" id="editRole">
<option value="user">User</option> <option value="mitarbeiter">Mitarbeiter</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
</div> </div>

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshDD-Dashboard Passwort ändern</title>
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="antialiased d-flex align-items-center justify-content-center" style="min-height:100vh">
<div class="card card-outline card-warning" style="width:100%;max-width:400px;margin:1rem">
<div class="card-header py-2">
<h6 class="mb-0"><i class="bi bi-key me-1"></i>Passwort ändern</h6>
</div>
<div class="card-body">
<p class="small text-body-secondary mb-3">
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
Dein Konto erfordert einen Passwortwechsel. Bitte wähle ein neues Passwort.
</p>
<div id="cpAlert" class="alert py-1 small d-none mb-2"></div>
<div class="mb-2">
<label class="form-label small mb-1">Neues Passwort (min. 8 Zeichen)</label>
<input type="password" class="form-control form-control-sm" id="cpPassword" minlength="8" required>
</div>
<div class="mb-3">
<label class="form-label small mb-1">Passwort bestätigen</label>
<input type="password" class="form-control form-control-sm" id="cpConfirm" required>
</div>
<button class="btn btn-warning w-100 btn-sm" id="btnChangePassword">Passwort setzen</button>
</div>
<div class="card-footer py-1 text-center">
<small class="text-body-secondary" id="cpFooter"></small>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/change-password.js"></script>
</body>
</html>

View file

@ -65,7 +65,7 @@ function renderUsers() {
tbody.innerHTML = users.map(user => { tbody.innerHTML = users.map(user => {
const roleBadge = user.role === 'admin' const roleBadge = user.role === 'admin'
? '<span class="badge bg-danger">Admin</span>' ? '<span class="badge bg-danger">Admin</span>'
: '<span class="badge bg-secondary">User</span>'; : '<span class="badge bg-info text-white">Mitarbeiter</span>';
const verifiedIcon = user.is_verified const verifiedIcon = user.is_verified
? '<i class="bi bi-check-circle-fill text-success"></i>' ? '<i class="bi bi-check-circle-fill text-success"></i>'
: '<i class="bi bi-x-circle text-danger"></i>'; : '<i class="bi bi-x-circle text-danger"></i>';
@ -110,57 +110,43 @@ function renderUsers() {
}).join(''); }).join('');
} }
// ── Create User ────────────────────────────────────── // ── Invite Mitarbeiter ───────────────────────────────
const createModal = new bootstrap.Modal(document.getElementById('createModal')); const inviteModal = new bootstrap.Modal(document.getElementById('inviteModal'));
document.getElementById('btnShowCreate').addEventListener('click', () => { document.getElementById('btnShowInvite').addEventListener('click', () => {
document.getElementById('createName').value = ''; document.getElementById('inviteName').value = '';
document.getElementById('createEmail').value = ''; document.getElementById('inviteEmail').value = '';
document.getElementById('createPassword').value = generatePassword(); hideModalAlert('inviteAlert');
document.getElementById('createRole').value = 'user'; inviteModal.show();
document.getElementById('createSendEmail').checked = true;
hideModalAlert('createAlert');
createModal.show();
}); });
document.getElementById('btnGeneratePassword').addEventListener('click', () => { document.getElementById('btnSendInvite').addEventListener('click', async () => {
document.getElementById('createPassword').value = generatePassword(); const name = document.getElementById('inviteName').value.trim();
}); const email = document.getElementById('inviteEmail').value.trim();
document.getElementById('btnCreateUser').addEventListener('click', async () => { if (!name || !email) {
const name = document.getElementById('createName').value.trim(); showModalAlert('inviteAlert', 'Name und E-Mail erforderlich');
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; return;
} }
try { try {
const resp = await fetch('/api/admin/users', { const resp = await fetch('/api/admin/invite', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, role, send_email: sendEmail }) body: JSON.stringify({ name, email })
}); });
if (resp.ok) { if (resp.ok) {
users = await resp.json(); users = await resp.json();
renderUsers(); renderUsers();
createModal.hide(); inviteModal.hide();
showToast(`Benutzer "${name}" erstellt`); showToast(`Einladung an "${name}" gesendet`);
} else { } else {
const data = await resp.json(); const data = await resp.json();
showModalAlert('createAlert', data.error || 'Fehler beim Erstellen'); showModalAlert('inviteAlert', data.error || 'Fehler beim Einladen');
} }
} catch (e) { } catch (e) {
showModalAlert('createAlert', 'Verbindungsfehler'); showModalAlert('inviteAlert', 'Verbindungsfehler');
} }
}); });

View file

@ -4,15 +4,15 @@
// ── Sidebar definition ──────────────────────────────────────── // ── Sidebar definition ────────────────────────────────────────
const _SIDEBAR_LINKS = [ const _SIDEBAR_LINKS = [
{ href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false, user: false }, { href: '/', icon: 'bi-speedometer2', label: 'Dashboard' },
{ href: '/map', icon: 'bi-map', label: 'Karte', admin: false, user: false }, { href: '/map', icon: 'bi-map', label: 'Karte' },
{ href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false, user: false }, { href: '/packets', icon: 'bi-reception-4', label: 'Pakete' },
{ href: '/messages', icon: 'bi-chat-dots', label: 'Nachrichten', admin: false, user: true }, { href: '/messages', icon: 'bi-chat-dots', label: 'Nachrichten' },
{ type: 'group', label: 'Konfigurationen',admin: true }, { type: 'group', label: 'Konfigurationen', staff: true },
{ href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true, user: false, sub: true }, { href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', staff: true, sub: true },
{ href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', admin: true, user: false, sub: true }, { href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', staff: true, sub: true },
{ href: '/config', icon: 'bi-sliders', label: 'Einstellungen', admin: true, user: false, sub: true }, { href: '/config', icon: 'bi-sliders', label: 'Einstellungen', staff: true, sub: true },
{ href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true, user: false }, { href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true },
]; ];
function _injectSidebar() { function _injectSidebar() {
@ -22,14 +22,14 @@ function _injectSidebar() {
sidebar.innerHTML = '<nav class="sidebar-nav">' + sidebar.innerHTML = '<nav class="sidebar-nav">' +
_SIDEBAR_LINKS.map(link => { _SIDEBAR_LINKS.map(link => {
if (link.type === 'group') { if (link.type === 'group') {
const adm = link.admin ? ' sidebar-admin' : ''; const cls = link.admin ? ' sidebar-admin' : link.staff ? ' sidebar-staff' : '';
return `<span class="sidebar-group-label${adm}">${link.label}</span>`; return `<span class="sidebar-group-label${cls}">${link.label}</span>`;
} }
const active = currentPath === link.href ? ' active' : ''; const active = currentPath === link.href ? ' active' : '';
const adm = link.admin ? ' sidebar-admin' : ''; const adm = link.admin ? ' sidebar-admin' : '';
const usr = link.user ? ' sidebar-user' : ''; const stf = link.staff ? ' sidebar-staff' : '';
const sub = link.sub ? ' sidebar-link-sub' : ''; const sub = link.sub ? ' sidebar-link-sub' : '';
return `<a href="${link.href}" class="sidebar-link${sub}${active}${adm}${usr}">` + return `<a href="${link.href}" class="sidebar-link${sub}${active}${adm}${stf}">` +
`<i class="bi ${link.icon}"></i><span>${link.label}</span></a>`; `<i class="bi ${link.icon}"></i><span>${link.label}</span></a>`;
}).join('') + }).join('') +
'</nav>'; '</nav>';
@ -53,12 +53,12 @@ function _updateNavbar(user) {
function _updateSidebar(user) { function _updateSidebar(user) {
const isAdmin = user && user.role === 'admin'; const isAdmin = user && user.role === 'admin';
const isUser = !!user; const isStaff = isAdmin || (user && user.role === 'mitarbeiter');
document.querySelectorAll('.sidebar-admin').forEach(el => { document.querySelectorAll('.sidebar-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none'; el.style.display = isAdmin ? '' : 'none';
}); });
document.querySelectorAll('.sidebar-user').forEach(el => { document.querySelectorAll('.sidebar-staff').forEach(el => {
el.style.display = isUser ? '' : 'none'; el.style.display = isStaff ? '' : 'none';
}); });
} }

View file

@ -0,0 +1,65 @@
// MeshDD-Dashboard Pflicht-Passwortwechsel
// Theme
(function () {
const t = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', t);
})();
// Footer
(function () {
const footer = document.getElementById('cpFooter');
if (!footer) return;
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
fetch('/api/stats').then(r => r.ok ? r.json() : null).then(d => {
const ver = d?.version ? ` · v${d.version}` : '';
footer.textContent = `© MeshDD / PPfeiffer${ver} · ${mm}/${now.getFullYear()}`;
}).catch(() => {});
})();
// Guard: redirect to login if not logged in
fetch('/api/auth/me').then(r => {
if (!r.ok) window.location.href = '/login';
}).catch(() => { window.location.href = '/login'; });
function showAlert(msg, type = 'danger') {
const el = document.getElementById('cpAlert');
el.className = `alert alert-${type} py-1 small`;
el.textContent = msg;
}
document.getElementById('btnChangePassword').addEventListener('click', async () => {
const password = document.getElementById('cpPassword').value;
const confirm = document.getElementById('cpConfirm').value;
if (password.length < 8) {
showAlert('Passwort muss mindestens 8 Zeichen lang sein');
return;
}
if (password !== confirm) {
showAlert('Passwoerter stimmen nicht ueberein');
return;
}
try {
const resp = await fetch('/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await resp.json();
if (resp.ok) {
showAlert(data.message || 'Passwort geaendert', 'success');
setTimeout(() => { window.location.href = '/'; }, 1500);
} else {
showAlert(data.error || 'Fehler beim Aendern');
}
} catch (e) {
showAlert('Verbindungsfehler');
}
});
document.getElementById('cpConfirm').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('btnChangePassword').click();
});

View file

@ -84,7 +84,7 @@ document.getElementById('btnLogin').addEventListener('click', async () => {
}); });
const data = await resp.json(); const data = await resp.json();
if (resp.ok) { if (resp.ok) {
window.location.href = '/'; window.location.href = data.force_password_change ? '/auth/change-password' : '/';
} else { } else {
showAlert('loginAlert', data.error || 'Anmeldung fehlgeschlagen', 'danger'); showAlert('loginAlert', data.error || 'Anmeldung fehlgeschlagen', 'danger');
} }

View file

@ -3,7 +3,26 @@ let agsCodes = [];
const MAX_ALERTS = 50; const MAX_ALERTS = 50;
const alerts = []; const alerts = [];
initPage({ onAuth: (user) => { currentUser = user; } }); initPage({ onAuth: (user) => {
currentUser = user;
if (!user || user.role !== 'admin') {
// Schreibkontrollen für Nur-Lesezugriff deaktivieren
['ninaEnabled', 'ninaSendToMesh', 'pollInterval', 'resendInterval',
'ninaChannel', 'minSeverity', 'agsInput', 'btnAddAgs', 'btnSaveNina',
'srcKatwarn', 'srcBiwapp', 'srcMowas', 'srcDwd', 'srcLhp', 'srcPolice']
.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = true;
});
const badge = document.createElement('span');
badge.className = 'badge bg-secondary-subtle text-secondary-emphasis ms-2';
badge.style.fontSize = '.65rem';
badge.textContent = 'Nur Lesezugriff';
const saveBtn = document.getElementById('btnSaveNina');
if (saveBtn && saveBtn.parentNode) saveBtn.parentNode.insertBefore(badge, saveBtn);
}
loadConfig();
} });
// ── Sachsen AGS-Lookup ──────────────────────────────────────────────────────── // ── Sachsen AGS-Lookup ────────────────────────────────────────────────────────
@ -288,6 +307,5 @@ function connectWebSocket() {
// ── Init ────────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────────
fillDatalist(); fillDatalist();
loadConfig();
loadAlerts(); loadAlerts();
connectWebSocket(); connectWebSocket();

View file

@ -4,7 +4,15 @@ let currentUser = null;
let jobs = []; let jobs = [];
let editMode = false; let editMode = false;
initPage({ onAuth: (user) => { currentUser = user; } }); initPage({ onAuth: (user) => {
currentUser = user;
if (!user || user.role !== 'admin') {
const btn = document.getElementById('btnAddJob');
btn.disabled = true;
btn.title = 'Nur Lesezugriff';
}
loadJobs();
} });
// Template variables available in message jobs // Template variables available in message jobs
const MSG_VARS = [ const MSG_VARS = [
@ -86,12 +94,20 @@ function renderJobs() {
jobsTable.innerHTML = '<tr><td colspan="8" class="text-center text-body-secondary py-3">Keine Jobs konfiguriert</td></tr>'; jobsTable.innerHTML = '<tr><td colspan="8" class="text-center text-body-secondary py-3">Keine Jobs konfiguriert</td></tr>';
return; return;
} }
const isAdmin = currentUser && currentUser.role === 'admin';
jobsTable.innerHTML = jobs.map(job => { jobsTable.innerHTML = jobs.map(job => {
const statusClass = job.enabled ? 'text-success' : 'text-body-secondary'; const statusClass = job.enabled ? 'text-success' : 'text-body-secondary';
const statusIcon = job.enabled ? 'bi-check-circle-fill' : 'bi-pause-circle'; const statusIcon = job.enabled ? 'bi-check-circle-fill' : 'bi-pause-circle';
const statusText = job.enabled ? 'Aktiv' : 'Inaktiv'; const statusText = job.enabled ? 'Aktiv' : 'Inaktiv';
const jobType = job.type === 'message' ? 'Nachricht' : 'Kommando'; const jobType = job.type === 'message' ? 'Nachricht' : 'Kommando';
const typeBadge = job.type === 'message' ? 'bg-warning text-dark' : 'bg-info text-white'; const typeBadge = job.type === 'message' ? 'bg-warning text-dark' : 'bg-info text-white';
const statusCell = isAdmin
? `<span class="${statusClass} cursor-pointer" role="button" onclick="toggleJob('${escapeHtml(job.name)}')"><i class="bi ${statusIcon} me-1"></i>${statusText}</span>`
: `<span class="${statusClass}"><i class="bi ${statusIcon} me-1"></i>${statusText}</span>`;
const actions = isAdmin
? `<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1" onclick="editJob('${escapeHtml(job.name)}')" title="Bearbeiten"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteJob('${escapeHtml(job.name)}')" title="Loeschen"><i class="bi bi-trash"></i></button>`
: `<span class="badge bg-secondary-subtle text-secondary-emphasis" style="font-size:.65rem">Nur Lesezugriff</span>`;
return `<tr> return `<tr>
<td class="fw-semibold">${escapeHtml(job.name || '')}</td> <td class="fw-semibold">${escapeHtml(job.name || '')}</td>
<td class="text-body-secondary">${escapeHtml(job.description || '')}</td> <td class="text-body-secondary">${escapeHtml(job.description || '')}</td>
@ -99,19 +115,8 @@ function renderJobs() {
<td><code>${escapeHtml(job.command || '')}</code></td> <td><code>${escapeHtml(job.command || '')}</code></td>
<td><code>${escapeHtml(job.cron || '')}</code></td> <td><code>${escapeHtml(job.cron || '')}</code></td>
<td class="text-center">${job.channel != null ? job.channel : 0}</td> <td class="text-center">${job.channel != null ? job.channel : 0}</td>
<td class="text-center"> <td class="text-center">${statusCell}</td>
<span class="${statusClass} cursor-pointer" role="button" onclick="toggleJob('${escapeHtml(job.name)}')"> <td class="text-end">${actions}</td>
<i class="bi ${statusIcon} me-1"></i>${statusText}
</span>
</td>
<td class="text-end">
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1" onclick="editJob('${escapeHtml(job.name)}')" title="Bearbeiten">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteJob('${escapeHtml(job.name)}')" title="Loeschen">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
@ -223,5 +228,4 @@ document.getElementById('btnSaveJob').addEventListener('click', async () => {
} }
}); });
loadJobs();
connectWebSocket(); connectWebSocket();