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
## [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
### Added

View file

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

View file

@ -78,6 +78,13 @@ def require_user_api(request: web.Request):
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):
if not request["user"]:
raise web.HTTPUnauthorized(text="Login required")
@ -155,6 +162,23 @@ async def send_reset_email(db, email: str, token: str):
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):
app_url = config.get("smtp.app_url", "http://localhost:8081")
login_url = f"{app_url}/login"
@ -201,6 +225,7 @@ def setup_auth_routes(app: web.Application, db):
"email": user["email"],
"name": user["name"],
"role": user["role"],
"force_password_change": bool(user.get("must_change_password", 0)),
})
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"])
data = await request.json()
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)
await db.update_user(user_id, role=role)
users = await db.get_all_users()
@ -359,13 +384,13 @@ def setup_auth_routes(app: web.Application, db):
name = data.get("name", "").strip()
email = data.get("email", "").strip().lower()
password = data.get("password", "")
role = data.get("role", "user")
role = data.get("role", "mitarbeiter")
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"):
if role not in ("mitarbeiter", "admin"):
return web.json_response({"error": "Ungueltige Rolle"}, status=400)
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:
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"):
if "role" in data and data["role"] in ("mitarbeiter", "admin"):
updates["role"] = data["role"]
if not updates:
@ -447,6 +472,53 @@ def setup_auth_routes(app: web.Application, db):
users = await db.get_all_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
app.router.add_post("/auth/login", login_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_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_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)
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,
password 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,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
@ -125,6 +126,19 @@ class Database:
await self.db.commit()
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 ──────────────────────────────────
async def upsert_node(self, node_id: str, **kwargs) -> dict:
@ -239,11 +253,13 @@ class Database:
# ── 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()
cursor = await self.db.execute(
"INSERT INTO users (email, password, name, role, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(email, password, name, role, is_verified, now, now),
"INSERT INTO users (email, password, name, role, is_verified, must_change_password, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(email, password, name, role, is_verified, must_change_password, now, now),
)
await self.db.commit()
return await self.get_user_by_id(cursor.lastrowid)
@ -264,7 +280,8 @@ class Database:
async def get_all_users(self) -> list[dict]:
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:
return [dict(row) async for row in cursor]

View file

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

View file

@ -41,8 +41,8 @@
<main class="content-wrapper">
<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>
<button class="btn btn-sm btn-info" id="btnShowCreate">
<i class="bi bi-person-plus me-1"></i>Neuer Benutzer
<button class="btn btn-sm btn-info" id="btnShowInvite">
<i class="bi bi-person-plus me-1"></i>Mitarbeiter einladen
</button>
</div>
@ -70,48 +70,29 @@
</div>
</main>
<!-- Create User Modal -->
<div class="modal fade" id="createModal" tabindex="-1">
<!-- Invite Modal -->
<div class="modal fade" id="inviteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<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>
</div>
<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">
<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 class="mb-2">
<label class="form-label small mb-1">E-Mail</label>
<input type="email" class="form-control form-control-sm" id="createEmail" 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>
<input type="email" class="form-control form-control-sm" id="inviteEmail" required>
</div>
</div>
<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-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>
@ -139,7 +120,7 @@
<div class="mb-2">
<label class="form-label small mb-1">Rolle</label>
<select class="form-select form-select-sm" id="editRole">
<option value="user">User</option>
<option value="mitarbeiter">Mitarbeiter</option>
<option value="admin">Admin</option>
</select>
</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 => {
const roleBadge = user.role === 'admin'
? '<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
? '<i class="bi bi-check-circle-fill text-success"></i>'
: '<i class="bi bi-x-circle text-danger"></i>';
@ -110,57 +110,43 @@ function renderUsers() {
}).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('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('btnShowInvite').addEventListener('click', () => {
document.getElementById('inviteName').value = '';
document.getElementById('inviteEmail').value = '';
hideModalAlert('inviteAlert');
inviteModal.show();
});
document.getElementById('btnGeneratePassword').addEventListener('click', () => {
document.getElementById('createPassword').value = generatePassword();
});
document.getElementById('btnSendInvite').addEventListener('click', async () => {
const name = document.getElementById('inviteName').value.trim();
const email = document.getElementById('inviteEmail').value.trim();
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');
if (!name || !email) {
showModalAlert('inviteAlert', 'Name und E-Mail erforderlich');
return;
}
try {
const resp = await fetch('/api/admin/users', {
const resp = await fetch('/api/admin/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, role, send_email: sendEmail })
body: JSON.stringify({ name, email })
});
if (resp.ok) {
users = await resp.json();
renderUsers();
createModal.hide();
showToast(`Benutzer "${name}" erstellt`);
inviteModal.hide();
showToast(`Einladung an "${name}" gesendet`);
} else {
const data = await resp.json();
showModalAlert('createAlert', data.error || 'Fehler beim Erstellen');
showModalAlert('inviteAlert', data.error || 'Fehler beim Einladen');
}
} catch (e) {
showModalAlert('createAlert', 'Verbindungsfehler');
showModalAlert('inviteAlert', 'Verbindungsfehler');
}
});

View file

@ -4,15 +4,15 @@
// ── Sidebar definition ────────────────────────────────────────
const _SIDEBAR_LINKS = [
{ href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false, user: false },
{ href: '/map', icon: 'bi-map', label: 'Karte', admin: false, user: false },
{ href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false, user: false },
{ href: '/messages', icon: 'bi-chat-dots', label: 'Nachrichten', admin: false, user: true },
{ type: 'group', label: 'Konfigurationen',admin: true },
{ href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true, user: false, sub: true },
{ href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', admin: true, user: false, sub: true },
{ href: '/config', icon: 'bi-sliders', label: 'Einstellungen', admin: true, user: false, sub: true },
{ href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true, user: false },
{ href: '/', icon: 'bi-speedometer2', label: 'Dashboard' },
{ href: '/map', icon: 'bi-map', label: 'Karte' },
{ href: '/packets', icon: 'bi-reception-4', label: 'Pakete' },
{ href: '/messages', icon: 'bi-chat-dots', label: 'Nachrichten' },
{ type: 'group', label: 'Konfigurationen', staff: true },
{ href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', staff: true, sub: true },
{ href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', staff: true, sub: true },
{ href: '/config', icon: 'bi-sliders', label: 'Einstellungen', staff: true, sub: true },
{ href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true },
];
function _injectSidebar() {
@ -22,14 +22,14 @@ function _injectSidebar() {
sidebar.innerHTML = '<nav class="sidebar-nav">' +
_SIDEBAR_LINKS.map(link => {
if (link.type === 'group') {
const adm = link.admin ? ' sidebar-admin' : '';
return `<span class="sidebar-group-label${adm}">${link.label}</span>`;
const cls = link.admin ? ' sidebar-admin' : link.staff ? ' sidebar-staff' : '';
return `<span class="sidebar-group-label${cls}">${link.label}</span>`;
}
const active = currentPath === link.href ? ' active' : '';
const adm = link.admin ? ' sidebar-admin' : '';
const usr = link.user ? ' sidebar-user' : '';
const adm = link.admin ? ' sidebar-admin' : '';
const stf = link.staff ? ' sidebar-staff' : '';
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>`;
}).join('') +
'</nav>';
@ -52,13 +52,13 @@ function _updateNavbar(user) {
}
function _updateSidebar(user) {
const isAdmin = user && user.role === 'admin';
const isUser = !!user;
const isAdmin = user && user.role === 'admin';
const isStaff = isAdmin || (user && user.role === 'mitarbeiter');
document.querySelectorAll('.sidebar-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
document.querySelectorAll('.sidebar-user').forEach(el => {
el.style.display = isUser ? '' : 'none';
document.querySelectorAll('.sidebar-staff').forEach(el => {
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();
if (resp.ok) {
window.location.href = '/';
window.location.href = data.force_password_change ? '/auth/change-password' : '/';
} else {
showAlert('loginAlert', data.error || 'Anmeldung fehlgeschlagen', 'danger');
}

View file

@ -3,7 +3,26 @@ let agsCodes = [];
const MAX_ALERTS = 50;
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 ────────────────────────────────────────────────────────
@ -288,6 +307,5 @@ function connectWebSocket() {
// ── Init ──────────────────────────────────────────────────────────────────────
fillDatalist();
loadConfig();
loadAlerts();
connectWebSocket();

View file

@ -4,7 +4,15 @@ let currentUser = null;
let jobs = [];
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
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>';
return;
}
const isAdmin = currentUser && currentUser.role === 'admin';
jobsTable.innerHTML = jobs.map(job => {
const statusClass = job.enabled ? 'text-success' : 'text-body-secondary';
const statusIcon = job.enabled ? 'bi-check-circle-fill' : 'bi-pause-circle';
const statusText = job.enabled ? 'Aktiv' : 'Inaktiv';
const jobType = job.type === 'message' ? 'Nachricht' : 'Kommando';
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>
<td class="fw-semibold">${escapeHtml(job.name || '')}</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.cron || '')}</code></td>
<td class="text-center">${job.channel != null ? job.channel : 0}</td>
<td class="text-center">
<span class="${statusClass} cursor-pointer" role="button" onclick="toggleJob('${escapeHtml(job.name)}')">
<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>
<td class="text-center">${statusCell}</td>
<td class="text-end">${actions}</td>
</tr>`;
}).join('');
}
@ -223,5 +228,4 @@ document.getElementById('btnSaveJob').addEventListener('click', async () => {
}
});
loadJobs();
connectWebSocket();