feat(dashboard): header user dropdown (Syncfusion) + proper logout; docs: clarify architecture; build: add splitbuttons; bump alpha.10

Dashboard

Add top-right user dropdown using Syncfusion DropDownButton: shows username + role; menu entries “Profil” and “Abmelden”.
Replace custom dropdown logic with Syncfusion component; position at header’s right edge.
Update /logout page to call backend logout and redirect to /login (reliable user switching).
Build/Config

Add @syncfusion/ej2-react-splitbuttons and @syncfusion/ej2-splitbuttons dependencies.
Update Vite optimizeDeps.include to pre-bundle splitbuttons and avoid import-analysis errors.
Docs

README: Rework Architecture Overview with clearer data flow:
Listener consumes MQTT (discovery/heartbeats) and updates API.
Scheduler reads from API and publishes events via MQTT to clients.
Clients send via MQTT and receive via MQTT.
Worker receives commands directly from API and reports results back (no MQTT).
Explicit note: MariaDB is accessed exclusively by the API Server; Dashboard never talks to DB directly.
README: Add SplitButtons to “Syncfusion Components Used”; add troubleshooting steps for @syncfusion/ej2-react-splitbuttons import issues (optimizeDeps + volume reset).
Copilot instructions: Document header user menu and splitbuttons technical notes (deps, optimizeDeps, dev-container node_modules volume).
Program info

Bump to 2025.1.0-alpha.10 with changelog:
UI: Header user menu (DropDownButton with username/role; Profil/Abmelden).
Frontend: Syncfusion SplitButtons integration + Vite pre-bundling config.
Fix: Added README guidance for splitbuttons import errors.
No breaking changes.
This commit is contained in:
RobbStarkAustria
2025-10-15 16:33:35 +00:00
parent 8676370fe2
commit a7df3c2708
35 changed files with 2217 additions and 59 deletions

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, jsonify, request
from server.permissions import admin_or_higher
from server.database import Session
from models.models import AcademicPeriod
from datetime import datetime
@@ -61,6 +62,7 @@ def get_period_for_date():
@academic_periods_bp.route('/active', methods=['POST'])
@admin_or_higher
def set_active_academic_period():
data = request.get_json(silent=True) or {}
period_id = data.get('id')

210
server/routes/auth.py Normal file
View File

@@ -0,0 +1,210 @@
"""
Authentication and user management routes.
This module provides endpoints for user authentication and role information.
Currently implements a basic session-based auth that can be extended with
JWT or Flask-Login later.
"""
from flask import Blueprint, request, jsonify, session
import os
from server.database import Session
from models.models import User, UserRole
import bcrypt
import sys
sys.path.append('/workspace')
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@auth_bp.route("/login", methods=["POST"])
def login():
"""
Authenticate a user and create a session.
Request body:
{
"username": "string",
"password": "string"
}
Returns:
200: {
"message": "Login successful",
"user": {
"id": int,
"username": "string",
"role": "string"
}
}
401: {"error": "Invalid credentials"}
400: {"error": "Username and password required"}
"""
data = request.get_json()
if not data:
return jsonify({"error": "Request body required"}), 400
username = data.get("username")
password = data.get("password")
if not username or not password:
return jsonify({"error": "Username and password required"}), 400
db_session = Session()
try:
# Find user by username
user = db_session.query(User).filter_by(username=username).first()
if not user:
return jsonify({"error": "Invalid credentials"}), 401
# Check if user is active
if not user.is_active:
return jsonify({"error": "Account is disabled"}), 401
# Verify password
if not bcrypt.checkpw(password.encode('utf-8'), user.password_hash.encode('utf-8')):
return jsonify({"error": "Invalid credentials"}), 401
# Create session
session['user_id'] = user.id
session['username'] = user.username
session['role'] = user.role.value
# Persist session across browser restarts (uses PERMANENT_SESSION_LIFETIME)
session.permanent = True
return jsonify({
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"role": user.role.value
}
}), 200
finally:
db_session.close()
@auth_bp.route("/logout", methods=["POST"])
def logout():
"""
End the current user session.
Returns:
200: {"message": "Logout successful"}
"""
session.clear()
return jsonify({"message": "Logout successful"}), 200
@auth_bp.route("/me", methods=["GET"])
def get_current_user():
"""
Get the current authenticated user's information.
Returns:
200: {
"id": int,
"username": "string",
"role": "string",
"is_active": bool
}
401: {"error": "Not authenticated"}
"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "Not authenticated"}), 401
db_session = Session()
try:
user = db_session.query(User).filter_by(id=user_id).first()
if not user:
# Session is stale, user was deleted
session.clear()
return jsonify({"error": "Not authenticated"}), 401
if not user.is_active:
# User was deactivated
session.clear()
return jsonify({"error": "Account is disabled"}), 401
return jsonify({
"id": user.id,
"username": user.username,
"role": user.role.value,
"is_active": user.is_active
}), 200
finally:
db_session.close()
@auth_bp.route("/check", methods=["GET"])
def check_auth():
"""
Quick check if user is authenticated (lighter than /me).
Returns:
200: {"authenticated": true, "role": "string"}
200: {"authenticated": false}
"""
user_id = session.get('user_id')
role = session.get('role')
if user_id and role:
return jsonify({
"authenticated": True,
"role": role
}), 200
return jsonify({"authenticated": False}), 200
@auth_bp.route("/dev-login-superadmin", methods=["POST"])
def dev_login_superadmin():
"""
Development-only endpoint to quickly establish a superadmin session without a password.
Enabled only when ENV is 'development' or 'dev'. Returns 404 otherwise.
"""
env = os.environ.get("ENV", "production").lower()
if env not in ("development", "dev"):
# Pretend the route does not exist in non-dev environments
return jsonify({"error": "Not found"}), 404
db_session = Session()
try:
# Prefer explicit username from env, else pick any superadmin
preferred_username = os.environ.get("DEFAULT_SUPERADMIN_USERNAME", "superadmin")
user = (
db_session.query(User)
.filter((User.username == preferred_username) | (User.role == UserRole.superadmin))
.order_by(User.id.asc())
.first()
)
if not user:
return jsonify({
"error": "No superadmin user found. Seed a superadmin first (DEFAULT_SUPERADMIN_PASSWORD)."
}), 404
# Establish session
session['user_id'] = user.id
session['username'] = user.username
session['role'] = user.role.value
session.permanent = True
return jsonify({
"message": "Dev login successful (superadmin)",
"user": {
"id": user.id,
"username": user.username,
"role": user.role.value
}
}), 200
finally:
db_session.close()

View File

@@ -1,6 +1,7 @@
from server.database import Session
from models.models import Client, ClientGroup
from flask import Blueprint, request, jsonify
from server.permissions import admin_or_higher
from server.mqtt_helper import publish_client_group, delete_client_group_message, publish_multiple_client_groups
import sys
sys.path.append('/workspace')
@@ -9,6 +10,7 @@ clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients")
@clients_bp.route("/sync-all-groups", methods=["POST"])
@admin_or_higher
def sync_all_client_groups():
"""
Administrative Route: Synchronisiert alle bestehenden Client-Gruppenzuordnungen mit MQTT
@@ -73,6 +75,7 @@ def get_clients_without_description():
@clients_bp.route("/<uuid>/description", methods=["PUT"])
@admin_or_higher
def set_client_description(uuid):
data = request.get_json()
description = data.get("description", "").strip()
@@ -127,6 +130,7 @@ def get_clients():
@clients_bp.route("/group", methods=["PUT"])
@admin_or_higher
def update_clients_group():
data = request.get_json()
client_ids = data.get("client_ids", [])
@@ -178,6 +182,7 @@ def update_clients_group():
@clients_bp.route("/<uuid>", methods=["PATCH"])
@admin_or_higher
def update_client(uuid):
data = request.get_json()
session = Session()
@@ -234,6 +239,7 @@ def get_clients_with_alive_status():
@clients_bp.route("/<uuid>/restart", methods=["POST"])
@admin_or_higher
def restart_client(uuid):
"""
Route to restart a specific client by UUID.
@@ -268,6 +274,7 @@ def restart_client(uuid):
@clients_bp.route("/<uuid>", methods=["DELETE"])
@admin_or_higher
def delete_client(uuid):
session = Session()
client = session.query(Client).filter_by(uuid=uuid).first()

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, jsonify, request
from server.permissions import editor_or_higher
from server.database import Session
from models.models import Conversion, ConversionStatus, EventMedia, MediaType
from server.task_queue import get_queue
@@ -19,6 +20,7 @@ def sha256_file(abs_path: str) -> str:
@conversions_bp.route("/<int:media_id>/pdf", methods=["POST"])
@editor_or_higher
def ensure_conversion(media_id: int):
session = Session()
try:

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, request, jsonify
from server.permissions import editor_or_higher
from server.database import Session
from models.models import EventException, Event
from datetime import datetime, date
@@ -7,6 +8,7 @@ event_exceptions_bp = Blueprint("event_exceptions", __name__, url_prefix="/api/e
@event_exceptions_bp.route("", methods=["POST"])
@editor_or_higher
def create_exception():
data = request.json
session = Session()
@@ -50,6 +52,7 @@ def create_exception():
@event_exceptions_bp.route("/<exc_id>", methods=["PUT"])
@editor_or_higher
def update_exception(exc_id):
data = request.json
session = Session()
@@ -77,6 +80,7 @@ def update_exception(exc_id):
@event_exceptions_bp.route("/<exc_id>", methods=["DELETE"])
@editor_or_higher
def delete_exception(exc_id):
session = Session()
exc = session.query(EventException).filter_by(id=exc_id).first()

View File

@@ -1,5 +1,6 @@
from re import A
from flask import Blueprint, request, jsonify, send_from_directory
from server.permissions import editor_or_higher
from server.database import Session
from models.models import EventMedia, MediaType, Conversion, ConversionStatus
from server.task_queue import get_queue
@@ -25,6 +26,7 @@ def get_param(key, default=None):
@eventmedia_bp.route('/filemanager/operations', methods=['GET', 'POST'])
@editor_or_higher
def filemanager_operations():
action = get_param('action')
path = get_param('path', '/')
@@ -115,6 +117,7 @@ def filemanager_operations():
@eventmedia_bp.route('/filemanager/upload', methods=['POST'])
@editor_or_higher
def filemanager_upload():
session = Session()
# Korrigiert: Erst aus request.form, dann aus request.args lesen
@@ -210,6 +213,7 @@ def list_media():
@eventmedia_bp.route('/<int:media_id>', methods=['PUT'])
@editor_or_higher
def update_media(media_id):
session = Session()
media = session.query(EventMedia).get(media_id)

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, request, jsonify
from server.permissions import editor_or_higher
from server.database import Session
from models.models import Event, EventMedia, MediaType, EventException
from datetime import datetime, timezone, timedelta
@@ -140,6 +141,7 @@ def get_event(event_id):
@events_bp.route("/<event_id>", methods=["DELETE"]) # delete series or single event
@editor_or_higher
def delete_event(event_id):
session = Session()
event = session.query(Event).filter_by(id=event_id).first()
@@ -162,7 +164,7 @@ def delete_event(event_id):
@events_bp.route("/<event_id>/occurrences/<occurrence_date>", methods=["DELETE"]) # skip single occurrence
@editor_or_higher
def delete_event_occurrence(event_id, occurrence_date):
"""Delete a single occurrence of a recurring event by creating an EventException."""
session = Session()
@@ -217,6 +219,7 @@ def delete_event_occurrence(event_id, occurrence_date):
@events_bp.route("/<event_id>/occurrences/<occurrence_date>/detach", methods=["POST"]) # detach single occurrence into standalone event
@editor_or_higher
def detach_event_occurrence(event_id, occurrence_date):
"""BULLETPROOF: Detach single occurrence without touching master event."""
session = Session()
@@ -322,6 +325,7 @@ def detach_event_occurrence(event_id, occurrence_date):
@events_bp.route("", methods=["POST"])
@editor_or_higher
def create_event():
data = request.json
session = Session()
@@ -438,6 +442,7 @@ def create_event():
@events_bp.route("/<event_id>", methods=["PUT"]) # update series or single event
@editor_or_higher
def update_event(event_id):
data = request.json
session = Session()

View File

@@ -4,6 +4,7 @@ from models.models import Client
from server.database import Session
from models.models import ClientGroup
from flask import Blueprint, request, jsonify
from server.permissions import admin_or_higher, require_role
from sqlalchemy import func
import sys
import os
@@ -41,6 +42,7 @@ def is_client_alive(last_alive, is_active):
@groups_bp.route("", methods=["POST"])
@admin_or_higher
def create_group():
data = request.get_json()
name = data.get("name")
@@ -83,6 +85,7 @@ def get_groups():
@groups_bp.route("/<int:group_id>", methods=["PUT"])
@admin_or_higher
def update_group(group_id):
data = request.get_json()
session = Session()
@@ -106,6 +109,7 @@ def update_group(group_id):
@groups_bp.route("/<int:group_id>", methods=["DELETE"])
@admin_or_higher
def delete_group(group_id):
session = Session()
group = session.query(ClientGroup).filter_by(id=group_id).first()
@@ -119,6 +123,7 @@ def delete_group(group_id):
@groups_bp.route("/byname/<string:group_name>", methods=["DELETE"])
@admin_or_higher
def delete_group_by_name(group_name):
session = Session()
group = session.query(ClientGroup).filter_by(name=group_name).first()
@@ -132,6 +137,7 @@ def delete_group_by_name(group_name):
@groups_bp.route("/byname/<string:old_name>", methods=["PUT"])
@admin_or_higher
def rename_group_by_name(old_name):
data = request.get_json()
new_name = data.get("newName")

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, request, jsonify
from server.permissions import admin_or_higher
from server.database import Session
from models.models import SchoolHoliday
from datetime import datetime
@@ -22,6 +23,7 @@ def list_holidays():
@holidays_bp.route("/upload", methods=["POST"])
@admin_or_higher
def upload_holidays():
"""
Accepts a CSV/TXT file upload (multipart/form-data).