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:
parent
f608f513a8
commit
c443a9f26d
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
version: "0.08.25"
|
||||
version: "0.08.26"
|
||||
|
||||
bot:
|
||||
name: "MeshDD-Bot"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
41
static/change-password.html
Normal file
41
static/change-password.html
Normal 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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
65
static/js/change-password.js
Normal file
65
static/js/change-password.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue