diff --git a/CHANGELOG.md b/CHANGELOG.md index 0300b7e..89e4c27 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/config/config.yaml b/config/config.yaml index 9d93dfc..22d912d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,4 +1,4 @@ -version: "0.08.25" +version: "0.08.26" bot: name: "MeshDD-Bot" diff --git a/meshbot/auth.py b/meshbot/auth.py index e4f0fc8..008aa80 100644 --- a/meshbot/auth.py +++ b/meshbot/auth.py @@ -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""" +

Willkommen bei MeshDD-Dashboard!

+

Hallo {name},

+

du wurdest als Mitarbeiter eingeladen. Hier sind deine Zugangsdaten:

+

E-Mail: {email}

+

Passwort: {password}

+

Anmelden unter: {login_url}

+

Bitte aendere dein Passwort nach dem ersten Login.

+""" + 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) diff --git a/meshbot/database.py b/meshbot/database.py index 54997b6..67ba4b0 100644 --- a/meshbot/database.py +++ b/meshbot/database.py @@ -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] diff --git a/meshbot/webserver.py b/meshbot/webserver.py index db0b6bf..8d5fe96 100644 --- a/meshbot/webserver.py +++ b/meshbot/webserver.py @@ -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()) diff --git a/static/admin.html b/static/admin.html index 6f969d0..8e9f16f 100644 --- a/static/admin.html +++ b/static/admin.html @@ -41,8 +41,8 @@
Benutzerverwaltung
-
@@ -70,48 +70,29 @@
- -