diff --git a/.env.example b/.env.example
index 466ec04..2095712 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,11 @@
# General
ENV=development
+# Flask
+# IMPORTANT: Generate a secure random key for production
+# e.g., python -c 'import secrets; print(secrets.token_hex(32))'
+FLASK_SECRET_KEY=dev-secret-key-change-in-production
+
# Database (used if DB_CONN not provided)
DB_USER=your_user
DB_PASSWORD=your_password
@@ -31,6 +36,7 @@ HEARTBEAT_GRACE_PERIOD_PROD=180
# Optional: force periodic republish even without changes
# REFRESH_SECONDS=0
-# Default admin bootstrap (server/init_defaults.py)
-DEFAULT_ADMIN_USERNAME=infoscreen_admin
-DEFAULT_ADMIN_PASSWORD=Info_screen_admin25!
+# Default superadmin bootstrap (server/init_defaults.py)
+# REQUIRED: Must be set for superadmin creation
+DEFAULT_SUPERADMIN_USERNAME=superadmin
+DEFAULT_SUPERADMIN_PASSWORD=your_secure_password_here
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 61b8f40..25a9efe 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -89,6 +89,16 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Migrated to Syncfusion inputs and popups: Buttons, TextBox, DropDownList, Dialog; Kanban remains for drag/drop.
- Unified toast/dialog wording; replaced legacy alerts with toasts; spacing handled via inline styles to avoid Tailwind dependency.
+- Header user menu (top-right):
+ - Shows current username and role; click opens a menu with “Profil” and “Abmelden”.
+ - Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`).
+ - “Abmelden” navigates to `/logout`; the page invokes backend logout and redirects to `/login`.
+
+- User dropdown technical notes:
+ - Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed.
+ - Vite: add both to `optimizeDeps.include` in `vite.config.ts` to avoid import-analysis errors.
+ - Dev containers: when `node_modules` is a named volume, recreate the dashboard node_modules volume after adding dependencies so `npm ci` runs inside the container.
+
Note: Syncfusion usage in the dashboard is already documented above; if a UI for conversion status/downloads is added later, link its routes and components here.
## Local development
diff --git a/AUTH_QUICKREF.md b/AUTH_QUICKREF.md
new file mode 100644
index 0000000..b1b9a0a
--- /dev/null
+++ b/AUTH_QUICKREF.md
@@ -0,0 +1,264 @@
+# Authentication Quick Reference
+
+## For Backend Developers
+
+### Protecting a Route
+
+```python
+from flask import Blueprint
+from server.permissions import require_role, admin_or_higher, editor_or_higher
+
+my_bp = Blueprint("myroute", __name__, url_prefix="/api/myroute")
+
+# Specific role(s)
+@my_bp.route("/admin")
+@require_role('admin', 'superadmin')
+def admin_only():
+ return {"message": "Admin only"}
+
+# Convenience decorators
+@my_bp.route("/settings")
+@admin_or_higher
+def settings():
+ return {"message": "Admin or superadmin"}
+
+@my_bp.route("/create", methods=["POST"])
+@editor_or_higher
+def create():
+ return {"message": "Editor, admin, or superadmin"}
+```
+
+### Getting Current User in Route
+
+```python
+from flask import session
+
+@my_bp.route("/profile")
+@require_auth
+def profile():
+ user_id = session.get('user_id')
+ username = session.get('username')
+ role = session.get('role')
+ return {
+ "user_id": user_id,
+ "username": username,
+ "role": role
+ }
+```
+
+## For Frontend Developers
+
+### Using the Auth Hook
+
+```typescript
+import { useAuth } from './useAuth';
+
+function MyComponent() {
+ const { user, isAuthenticated, login, logout, loading } = useAuth();
+
+ if (loading) return
Loading...
;
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return (
+
+
Welcome {user?.username}
+
Role: {user?.role}
+
+
+ );
+}
+```
+
+### Conditional Rendering
+
+```typescript
+import { useCurrentUser } from './useAuth';
+import { isAdminOrHigher, isEditorOrHigher } from './apiAuth';
+
+function Navigation() {
+ const user = useCurrentUser();
+
+ return (
+
+ );
+}
+```
+
+### Making Authenticated API Calls
+
+```typescript
+// Always include credentials for session cookies
+const response = await fetch('/api/protected-route', {
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ // ... other options
+});
+```
+
+## Role Hierarchy
+
+```
+superadmin > admin > editor > user
+```
+
+| Role | Can Do |
+|------|--------|
+| **user** | View events |
+| **editor** | user + CRUD events/media |
+| **admin** | editor + manage users/groups/settings |
+| **superadmin** | admin + manage superadmins + system config |
+
+## Environment Variables
+
+```bash
+# Required for sessions
+FLASK_SECRET_KEY=your_secret_key_here
+
+# Required for superadmin creation
+DEFAULT_SUPERADMIN_USERNAME=superadmin
+DEFAULT_SUPERADMIN_PASSWORD=your_password_here
+```
+
+Generate a secret key:
+```bash
+python -c 'import secrets; print(secrets.token_hex(32))'
+```
+
+## Testing Endpoints
+
+```bash
+# Login
+curl -X POST http://localhost:8000/api/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"superadmin","password":"your_password"}' \
+ -c cookies.txt
+
+# Check current user
+curl http://localhost:8000/api/auth/me -b cookies.txt
+
+# Check auth status (lightweight)
+curl http://localhost:8000/api/auth/check -b cookies.txt
+
+# Logout
+curl -X POST http://localhost:8000/api/auth/logout -b cookies.txt
+
+# Test protected route
+curl http://localhost:8000/api/protected -b cookies.txt
+```
+
+## Common Patterns
+
+### Backend: Optional Auth
+
+```python
+from flask import session
+
+@my_bp.route("/public-with-extras")
+def public_route():
+ user_id = session.get('user_id')
+
+ if user_id:
+ # Show extra content for authenticated users
+ return {"data": "...", "extras": "..."}
+ else:
+ # Public content only
+ return {"data": "..."}
+```
+
+### Frontend: Redirect After Login
+
+```typescript
+const { login } = useAuth();
+
+const handleLogin = async (username: string, password: string) => {
+ try {
+ await login(username, password);
+ window.location.href = '/dashboard';
+ } catch (err) {
+ console.error('Login failed:', err);
+ }
+};
+```
+
+### Frontend: Protected Route Component
+
+```typescript
+import { useAuth } from './useAuth';
+import { Navigate } from 'react-router-dom';
+
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated, loading } = useAuth();
+
+ if (loading) return Loading...
;
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return <>{children}>;
+}
+
+// Usage in routes:
+
+
+
+} />
+```
+
+## Troubleshooting
+
+### "Authentication required" on /api/auth/me
+
+✅ **Normal** - User is not logged in. This is expected behavior.
+
+### Session not persisting across requests
+
+- Check `credentials: 'include'` in fetch calls
+- Verify `FLASK_SECRET_KEY` is set
+- Check browser cookies are enabled
+
+### 403 Forbidden on decorated route
+
+- Verify user is logged in
+- Check user role matches required role
+- Inspect response for `required_roles` and `your_role`
+
+## Files Reference
+
+| File | Purpose |
+|------|---------|
+| `server/routes/auth.py` | Auth endpoints (login, logout, /me) |
+| `server/permissions.py` | Permission decorators |
+| `dashboard/src/apiAuth.ts` | Frontend API client |
+| `dashboard/src/useAuth.tsx` | React context/hooks |
+| `models/models.py` | User model and UserRole enum |
+
+## Full Documentation
+
+See `AUTH_SYSTEM.md` for complete documentation including:
+- Architecture details
+- Security considerations
+- API reference
+- Testing guide
+- Production checklist
diff --git a/AUTH_SYSTEM.md b/AUTH_SYSTEM.md
new file mode 100644
index 0000000..12ad4fb
--- /dev/null
+++ b/AUTH_SYSTEM.md
@@ -0,0 +1,522 @@
+# Authentication System Documentation
+
+This document describes the authentication and authorization system implemented in the infoscreen_2025 project.
+
+## Overview
+
+The system provides session-based authentication with role-based access control (RBAC). It includes:
+
+- **Backend**: Flask session-based auth with bcrypt password hashing
+- **Frontend**: React context/hooks for managing authentication state
+- **Permissions**: Decorators for protecting routes based on user roles
+- **Roles**: Four levels (user, editor, admin, superadmin)
+
+## Architecture
+
+### Backend Components
+
+#### 1. Auth Routes (`server/routes/auth.py`)
+
+Provides authentication endpoints:
+
+- **`POST /api/auth/login`** - Authenticate user and create session
+- **`POST /api/auth/logout`** - End user session
+- **`GET /api/auth/me`** - Get current user info (protected)
+- **`GET /api/auth/check`** - Quick auth status check
+
+#### 2. Permission Decorators (`server/permissions.py`)
+
+Decorators for protecting routes:
+
+```python
+from server.permissions import require_role, admin_or_higher, editor_or_higher
+
+# Require specific role(s)
+@app.route('/admin-settings')
+@require_role('admin', 'superadmin')
+def admin_settings():
+ return "Admin only"
+
+# Convenience decorators
+@app.route('/settings')
+@admin_or_higher # admin or superadmin
+def settings():
+ return "Settings"
+
+@app.route('/events', methods=['POST'])
+@editor_or_higher # editor, admin, or superadmin
+def create_event():
+ return "Create event"
+```
+
+Available decorators:
+- `@require_auth` - Just require authentication
+- `@require_role(*roles)` - Require any of specified roles
+- `@superadmin_only` - Superadmin only
+- `@admin_or_higher` - Admin or superadmin
+- `@editor_or_higher` - Editor, admin, or superadmin
+
+#### 3. Session Configuration (`server/wsgi.py`)
+
+Flask session configured with:
+- Secret key from `FLASK_SECRET_KEY` environment variable
+- HTTPOnly cookies (prevent XSS)
+- SameSite=Lax (CSRF protection)
+- Secure flag in production (HTTPS only)
+
+### Frontend Components
+
+#### 1. API Client (`dashboard/src/apiAuth.ts`)
+
+TypeScript functions for auth operations:
+
+```typescript
+import { login, logout, fetchCurrentUser } from './apiAuth';
+
+// Login
+await login('username', 'password');
+
+// Get current user
+const user = await fetchCurrentUser();
+
+// Logout
+await logout();
+
+// Check auth status (lightweight)
+const { authenticated, role } = await checkAuth();
+```
+
+Helper functions:
+```typescript
+import { hasRole, hasAnyRole, isAdminOrHigher } from './apiAuth';
+
+if (isAdminOrHigher(user)) {
+ // Show admin UI
+}
+```
+
+#### 2. Auth Context/Hooks (`dashboard/src/useAuth.tsx`)
+
+React context for managing auth state:
+
+```typescript
+import { useAuth, useCurrentUser, useIsAuthenticated } from './useAuth';
+
+function MyComponent() {
+ // Full auth context
+ const { user, login, logout, loading, error, isAuthenticated } = useAuth();
+
+ // Or just what you need
+ const user = useCurrentUser();
+ const isAuth = useIsAuthenticated();
+
+ if (loading) return Loading...
;
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return Welcome {user.username}!
;
+}
+```
+
+## User Roles
+
+Four hierarchical roles with increasing permissions:
+
+| Role | Value | Description | Use Case |
+|------|-------|-------------|----------|
+| **User** | `user` | Read-only access | View events only |
+| **Editor** | `editor` | Can CRUD events/media | Content managers |
+| **Admin** | `admin` | Manage settings, users (except superadmin), groups | Organization staff |
+| **Superadmin** | `superadmin` | Full system access | Developers, system admins |
+
+### Permission Matrix
+
+| Action | User | Editor | Admin | Superadmin |
+|--------|------|--------|-------|------------|
+| View events | ✅ | ✅ | ✅ | ✅ |
+| Create/edit events | ❌ | ✅ | ✅ | ✅ |
+| Manage media | ❌ | ✅ | ✅ | ✅ |
+| Manage groups/clients | ❌ | ❌ | ✅ | ✅ |
+| Manage users (non-superadmin) | ❌ | ❌ | ✅ | ✅ |
+| Manage settings | ❌ | ❌ | ✅ | ✅ |
+| Manage superadmins | ❌ | ❌ | ❌ | ✅ |
+| System configuration | ❌ | ❌ | ❌ | ✅ |
+
+## Setup Instructions
+
+### 1. Environment Configuration
+
+Add to your `.env` file:
+
+```bash
+# Flask session secret key (REQUIRED)
+# Generate with: python -c 'import secrets; print(secrets.token_hex(32))'
+FLASK_SECRET_KEY=your_secret_key_here
+
+# Superadmin account (REQUIRED for initial setup)
+DEFAULT_SUPERADMIN_USERNAME=superadmin
+DEFAULT_SUPERADMIN_PASSWORD=your_secure_password
+```
+
+### 2. Database Initialization
+
+The superadmin user is created automatically when containers start. See `SUPERADMIN_SETUP.md` for details.
+
+### 3. Frontend Integration
+
+Wrap your app with `AuthProvider` in `main.tsx` or `App.tsx`:
+
+```typescript
+import { AuthProvider } from './useAuth';
+
+function App() {
+ return (
+
+ {/* Your app components */}
+
+ );
+}
+```
+
+## Usage Examples
+
+### Backend: Protecting Routes
+
+```python
+from flask import Blueprint
+from server.permissions import require_role, admin_or_higher
+
+users_bp = Blueprint("users", __name__, url_prefix="/api/users")
+
+@users_bp.route("", methods=["GET"])
+@admin_or_higher
+def list_users():
+ """List all users - admin+ only"""
+ # Implementation
+ pass
+
+@users_bp.route("", methods=["POST"])
+@require_role('superadmin')
+def create_superadmin():
+ """Create superadmin - superadmin only"""
+ # Implementation
+ pass
+```
+
+### Frontend: Conditional Rendering
+
+```typescript
+import { useAuth } from './useAuth';
+import { isAdminOrHigher, isEditorOrHigher } from './apiAuth';
+
+function NavigationMenu() {
+ const { user } = useAuth();
+
+ return (
+
+ );
+}
+```
+
+### Frontend: Login Form Example
+
+```typescript
+import { useState } from 'react';
+import { useAuth } from './useAuth';
+
+function LoginPage() {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const { login, loading, error } = useAuth();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ await login(username, password);
+ // Redirect on success
+ window.location.href = '/dashboard';
+ } catch (err) {
+ // Error is already in auth context
+ console.error('Login failed:', err);
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+## Security Considerations
+
+### Backend Security
+
+1. **Password Hashing**: All passwords hashed with bcrypt (salt rounds default)
+2. **Session Security**:
+ - HTTPOnly cookies (prevent XSS access)
+ - SameSite=Lax (CSRF protection)
+ - Secure flag in production (HTTPS only)
+3. **Secret Key**: Must be set via environment variable, not hardcoded
+4. **Role Checking**: Server-side validation on every protected route
+
+### Frontend Security
+
+1. **Credentials**: Always use `credentials: 'include'` in fetch calls
+2. **No Password Storage**: Never store passwords in localStorage/sessionStorage
+3. **Role Gating**: UI gating is convenience, not security (always validate server-side)
+4. **HTTPS**: Always use HTTPS in production
+
+### Production Checklist
+
+- [ ] Generate strong `FLASK_SECRET_KEY` (32+ bytes)
+- [ ] Set `SESSION_COOKIE_SECURE=True` (handled automatically by ENV=production)
+- [ ] Use HTTPS with valid TLS certificate
+- [ ] Change default superadmin password after first login
+- [ ] Review and audit user roles regularly
+- [ ] Enable audit logging (future enhancement)
+
+## API Reference
+
+### Authentication Endpoints
+
+#### POST /api/auth/login
+
+Authenticate user and create session.
+
+**Request:**
+```json
+{
+ "username": "string",
+ "password": "string"
+}
+```
+
+**Response (200):**
+```json
+{
+ "message": "Login successful",
+ "user": {
+ "id": 1,
+ "username": "admin",
+ "role": "admin"
+ }
+}
+```
+
+**Errors:**
+- `400` - Missing username or password
+- `401` - Invalid credentials or account disabled
+
+#### POST /api/auth/logout
+
+End current session.
+
+**Response (200):**
+```json
+{
+ "message": "Logout successful"
+}
+```
+
+#### GET /api/auth/me
+
+Get current user information (requires authentication).
+
+**Response (200):**
+```json
+{
+ "id": 1,
+ "username": "admin",
+ "role": "admin",
+ "is_active": true
+}
+```
+
+**Errors:**
+- `401` - Not authenticated or account disabled
+
+#### GET /api/auth/check
+
+Quick authentication status check.
+
+**Response (200):**
+```json
+{
+ "authenticated": true,
+ "role": "admin"
+}
+```
+
+Or if not authenticated:
+```json
+{
+ "authenticated": false
+}
+```
+
+## Testing
+
+### Manual Testing
+
+1. **Create test users** (via database or future user management UI):
+ ```sql
+ INSERT INTO users (username, password_hash, role, is_active)
+ VALUES ('testuser', '', 'user', 1);
+ ```
+
+2. **Test login**:
+ ```bash
+ curl -X POST http://localhost:8000/api/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"superadmin","password":"your_password"}' \
+ -c cookies.txt
+ ```
+
+3. **Test /me endpoint**:
+ ```bash
+ curl http://localhost:8000/api/auth/me -b cookies.txt
+ ```
+
+4. **Test protected route**:
+ ```bash
+ # Should fail without auth
+ curl http://localhost:8000/api/protected
+
+ # Should work with cookie
+ curl http://localhost:8000/api/protected -b cookies.txt
+ ```
+
+### Automated Testing
+
+Example test cases (to be implemented):
+
+```python
+def test_login_success():
+ response = client.post('/api/auth/login', json={
+ 'username': 'testuser',
+ 'password': 'testpass'
+ })
+ assert response.status_code == 200
+ assert 'user' in response.json
+
+def test_login_invalid_credentials():
+ response = client.post('/api/auth/login', json={
+ 'username': 'testuser',
+ 'password': 'wrongpass'
+ })
+ assert response.status_code == 401
+
+def test_me_authenticated():
+ # Login first
+ client.post('/api/auth/login', json={'username': 'testuser', 'password': 'testpass'})
+ response = client.get('/api/auth/me')
+ assert response.status_code == 200
+ assert response.json['username'] == 'testuser'
+
+def test_me_not_authenticated():
+ response = client.get('/api/auth/me')
+ assert response.status_code == 401
+```
+
+## Troubleshooting
+
+### Login Not Working
+
+**Symptoms**: Login endpoint returns 401 even with correct credentials
+
+**Solutions**:
+1. Verify user exists in database: `SELECT * FROM users WHERE username='...'`
+2. Check password hash is valid bcrypt format
+3. Verify user `is_active=1`
+4. Check server logs for bcrypt errors
+
+### Session Not Persisting
+
+**Symptoms**: `/api/auth/me` returns 401 after successful login
+
+**Solutions**:
+1. Verify `FLASK_SECRET_KEY` is set
+2. Check frontend is sending `credentials: 'include'` in fetch
+3. Verify cookies are being set (check browser DevTools)
+4. Check CORS settings if frontend/backend on different domains
+
+### Permission Denied on Protected Route
+
+**Symptoms**: 403 error on decorated routes
+
+**Solutions**:
+1. Verify user is logged in (`/api/auth/me`)
+2. Check user role matches required role
+3. Verify decorator is applied correctly
+4. Check session hasn't expired
+
+### TypeScript Errors in Frontend
+
+**Symptoms**: Type errors when using auth hooks
+
+**Solutions**:
+1. Ensure `AuthProvider` is wrapping your app
+2. Import types correctly: `import type { User } from './apiAuth'`
+3. Check TypeScript config for `verbatimModuleSyntax`
+
+## Next Steps
+
+See `userrole-management.md` for the complete implementation roadmap:
+
+1. ✅ **Extend User Model** - Done
+2. ✅ **Seed Superadmin** - Done (`init_defaults.py`)
+3. ✅ **Expose Current User Role** - Done (this document)
+4. ⏳ **Implement Minimal Role Enforcement** - Apply decorators to existing routes
+5. ⏳ **Test the Flow** - Verify permissions work correctly
+6. ⏳ **Frontend Role Gating** - Update UI components
+7. ⏳ **User Management UI** - Build admin interface
+
+## References
+
+- User model: `models/models.py`
+- Auth routes: `server/routes/auth.py`
+- Permissions: `server/permissions.py`
+- API client: `dashboard/src/apiAuth.ts`
+- Auth context: `dashboard/src/useAuth.tsx`
+- Flask sessions: https://flask.palletsprojects.com/en/latest/api/#sessions
+- Bcrypt: https://pypi.org/project/bcrypt/
diff --git a/README.md b/README.md
index 20d063e..c5b51f4 100644
--- a/README.md
+++ b/README.md
@@ -11,32 +11,39 @@ A comprehensive multi-service digital signage solution for educational instituti
## 🏗️ Architecture Overview
```
-┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
-│ Dashboard │ │ API Server │ │ Listener │
-│ (React/Vite) │◄──►│ (Flask) │◄──►│ (MQTT Client) │
-└─────────────────┘ └─────────────────┘ └─────────────────┘
- │ │ │
- │ ▼ │
- │ ┌─────────────────┐ │
- │ │ MariaDB │ │
- │ │ (Database) │ │
- │ └─────────────────┘ │
- │ │
- └────────────────────┬───────────────────────────┘
- ▼
- ┌─────────────────┐
- │ MQTT Broker │
- │ (Mosquitto) │
- └─────────────────┘
- │
- ┌────────────────────┼────────────────────┐
- ▼ ▼ ▼
-┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
-│ Scheduler │ │ Worker │ │ Infoscreen │
-│ (Events) │ │ (Conversions) │ │ Clients │
-└─────────────────┘ └─────────────────┘ └─────────────────┘
+┌───────────────┐ ┌──────────────────────────┐ ┌───────────────┐
+│ Dashboard │◄──────►│ API Server │◄──────►│ Worker │
+│ (React/Vite) │ │ (Flask) │ │ (Conversions) │
+└───────────────┘ └──────────────────────────┘ └───────────────┘
+ ▲ ▲
+ │ │
+ ┌───────────────┐ │
+ │ MariaDB │ │
+ │ (Database) │ │
+ └───────────────┘ │
+ │ direct commands/results
+
+ Reads events ▲ Interacts with API ▲
+ │ ┌────┘
+┌───────────────┐ │ │ ┌───────────────┐
+│ Scheduler │──┘ └──│ Listener │
+│ (Events) │ │ (MQTT Client) │
+└───────────────┘ └───────────────┘
+ │ Publishes events ▲ Consumes discovery/heartbeats
+ ▼ │ and forwards to API
+┌─────────────────┐◄─────────────────────────────────────────────────────────────────┘
+│ MQTT Broker │────────────────────────────────────────────────────────► Clients
+│ (Mosquitto) │ Sends events to clients (send discovery/heartbeats)
+└─────────────────┘
```
+Data flow summary:
+- Listener: consumes discovery and heartbeat messages from the MQTT Broker and updates the API Server (client registration/heartbeats).
+- Scheduler: reads events from the API Server and publishes active content to the MQTT Broker (retained topics per group) for clients.
+- Clients: send discovery/heartbeat via the MQTT Broker (handled by the Listener) and receive content from the Scheduler via MQTT.
+- Worker: receives conversion commands directly from the API Server and reports results/status back to the API (no MQTT involved).
+- MariaDB: is accessed exclusively by the API Server. The Dashboard never talks to the database directly; it only communicates with the API.
+
## 🌟 Key Features
@@ -145,6 +152,7 @@ For detailed deployment instructions, see:
- **Styling**: Centralized Syncfusion Material 3 CSS imports in `dashboard/src/main.tsx`
- **Features**: Responsive design, real-time updates, file management
- **Port**: 5173 (dev), served via Nginx (prod)
+ - **Data access**: No direct database connection; communicates with the API Server only via HTTP.
### 🔧 **API Server** (`server/`)
- **Technology**: Flask + SQLAlchemy + Alembic
@@ -299,6 +307,7 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- **Notifications**: Toast messages and alerts
- **Pager**: Used on Programinfo changelog for pagination
- **Cards (layouts)**: Programinfo sections styled with Syncfusion card classes
+ - **SplitButtons**: Header user menu (top-right) using Syncfusion DropDownButton to show current user and role, with actions “Profil” and “Abmelden”.
### Pages Overview
- **Dashboard**: System overview and statistics
@@ -402,6 +411,21 @@ docker exec -it infoscreen-db mysqladmin ping
```
**MQTT communication issues**
+**Vite import-analysis errors (Syncfusion splitbuttons)**
+```bash
+# Symptom
+[plugin:vite:import-analysis] Failed to resolve import "@syncfusion/ej2-react-splitbuttons"
+
+# Fix
+# 1) Ensure dependencies are added in dashboard/package.json:
+# - @syncfusion/ej2-react-splitbuttons, @syncfusion/ej2-splitbuttons
+# 2) In dashboard/vite.config.ts, add to optimizeDeps.include:
+# '@syncfusion/ej2-react-splitbuttons', '@syncfusion/ej2-splitbuttons'
+# 3) If dashboard uses a named node_modules volume, recreate it so npm ci runs inside the container:
+docker compose rm -sf dashboard
+docker volume rm _dashboard-node-modules _dashboard-vite-cache || true
+docker compose up -d --build dashboard
+```
```bash
# Test MQTT broker
mosquitto_pub -h localhost -t test -m "hello"
diff --git a/SUPERADMIN_SETUP.md b/SUPERADMIN_SETUP.md
new file mode 100644
index 0000000..54ccbc0
--- /dev/null
+++ b/SUPERADMIN_SETUP.md
@@ -0,0 +1,159 @@
+# Superadmin User Setup
+
+This document describes the superadmin user initialization system implemented in the infoscreen_2025 project.
+
+## Overview
+
+The system automatically creates a default superadmin user during database initialization if one doesn't already exist. This ensures there's always an initial administrator account available for system setup and configuration.
+
+## Implementation Details
+
+### Files Modified
+
+1. **`server/init_defaults.py`**
+ - Updated to create a superadmin user with role `superadmin` (from `UserRole` enum)
+ - Password is securely hashed using bcrypt
+ - Only creates user if not already present in the database
+ - Provides clear feedback about creation status
+
+2. **`.env.example`**
+ - Updated with new environment variables
+ - Includes documentation for required variables
+
+3. **`docker-compose.yml`** and **`docker-compose.prod.yml`**
+ - Added environment variable passthrough for superadmin credentials
+
+4. **`userrole-management.md`**
+ - Marked stage 1, step 2 as completed
+
+## Environment Variables
+
+### Required
+
+- **`DEFAULT_SUPERADMIN_PASSWORD`**: The password for the superadmin user
+ - **IMPORTANT**: This must be set for the superadmin user to be created
+ - Should be a strong, secure password
+ - If not set, the script will skip superadmin creation with a warning
+
+### Optional
+
+- **`DEFAULT_SUPERADMIN_USERNAME`**: The username for the superadmin user
+ - Default: `superadmin`
+ - Can be customized if needed
+
+## Setup Instructions
+
+### Development
+
+1. Copy `.env.example` to `.env`:
+ ```bash
+ cp .env.example .env
+ ```
+
+2. Edit `.env` and set a secure password:
+ ```bash
+ DEFAULT_SUPERADMIN_USERNAME=superadmin
+ DEFAULT_SUPERADMIN_PASSWORD=your_secure_password_here
+ ```
+
+3. Run the initialization (happens automatically on container startup):
+ ```bash
+ docker-compose up -d
+ ```
+
+### Production
+
+1. Set environment variables in your deployment configuration:
+ ```bash
+ export DEFAULT_SUPERADMIN_USERNAME=superadmin
+ export DEFAULT_SUPERADMIN_PASSWORD=your_very_secure_password
+ ```
+
+2. Deploy with docker-compose:
+ ```bash
+ docker-compose -f docker-compose.prod.yml up -d
+ ```
+
+## Behavior
+
+The `init_defaults.py` script runs automatically during container initialization and:
+
+1. Checks if the username already exists in the database
+2. If it exists: Prints an info message and skips creation
+3. If it doesn't exist and `DEFAULT_SUPERADMIN_PASSWORD` is set:
+ - Hashes the password with bcrypt
+ - Creates the user with role `superadmin`
+ - Prints a success message
+4. If `DEFAULT_SUPERADMIN_PASSWORD` is not set:
+ - Prints a warning and skips creation
+
+## Security Considerations
+
+1. **Never commit the `.env` file** to version control
+2. Use a strong password (minimum 12 characters, mixed case, numbers, special characters)
+3. Change the default password after first login
+4. In production, consider using secrets management (Docker secrets, Kubernetes secrets, etc.)
+5. Rotate passwords regularly
+6. The password is hashed with bcrypt (industry standard) before storage
+
+## Testing
+
+To verify the superadmin user was created:
+
+```bash
+# Connect to the database container
+docker exec -it infoscreen-db mysql -u root -p
+
+# Check the users table
+USE infoscreen_by_taa;
+SELECT username, role, is_active FROM users WHERE role = 'superadmin';
+```
+
+Expected output:
+```
++------------+------------+-----------+
+| username | role | is_active |
++------------+------------+-----------+
+| superadmin | superadmin | 1 |
++------------+------------+-----------+
+```
+
+## Troubleshooting
+
+### Superadmin not created
+
+**Symptoms**: No superadmin user in database
+
+**Solutions**:
+1. Check if `DEFAULT_SUPERADMIN_PASSWORD` is set in environment
+2. Check container logs: `docker logs infoscreen-api`
+3. Look for warning message: "⚠️ DEFAULT_SUPERADMIN_PASSWORD nicht gesetzt"
+
+### User already exists message
+
+**Symptoms**: Script says user already exists but you can't log in
+
+**Solutions**:
+1. Verify the username is correct
+2. Reset the password manually in the database
+3. Or delete the user and restart containers to recreate
+
+### Permission denied errors
+
+**Symptoms**: Database connection errors during initialization
+
+**Solutions**:
+1. Verify `DB_USER`, `DB_PASSWORD`, and `DB_NAME` environment variables
+2. Check database container is healthy: `docker ps`
+3. Verify database connectivity: `docker exec infoscreen-api ping -c 1 db`
+
+## Next Steps
+
+After setting up the superadmin user:
+
+1. Implement the `/api/me` endpoint (Stage 1, Step 3)
+2. Add authentication/session management
+3. Create permission decorators (Stage 1, Step 4)
+4. Build user management UI (Stage 2)
+
+See `userrole-management.md` for the complete roadmap.
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 0ccd86c..8ddbfa1 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -34,6 +34,7 @@
"@syncfusion/ej2-react-notifications": "^30.2.0",
"@syncfusion/ej2-react-popups": "^30.2.0",
"@syncfusion/ej2-react-schedule": "^30.2.0",
+ "@syncfusion/ej2-react-splitbuttons": "^30.2.0",
"@syncfusion/ej2-splitbuttons": "^30.2.0",
"cldr-data": "^36.0.4",
"lucide-react": "^0.522.0",
@@ -1940,6 +1941,17 @@
"@syncfusion/ej2-schedule": "30.2.7"
}
},
+ "node_modules/@syncfusion/ej2-react-splitbuttons": {
+ "version": "30.2.4",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-splitbuttons/-/ej2-react-splitbuttons-30.2.4.tgz",
+ "integrity": "sha512-HgfdC2qhRo4XYMaTPqukxM0BmYbmxFQ/DLiRQ71+/4nXHVTAwFYgHie1PkKekp+GPm7L+u5b6h8PEmnQiuYptw==",
+ "license": "SEE LICENSE IN license",
+ "dependencies": {
+ "@syncfusion/ej2-base": "~30.2.4",
+ "@syncfusion/ej2-react-base": "~30.2.4",
+ "@syncfusion/ej2-splitbuttons": "30.2.4"
+ }
+ },
"node_modules/@syncfusion/ej2-schedule": {
"version": "30.2.7",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-schedule/-/ej2-schedule-30.2.7.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index 970b58a..a0f7ddf 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -36,6 +36,7 @@
"@syncfusion/ej2-react-notifications": "^30.2.0",
"@syncfusion/ej2-react-popups": "^30.2.0",
"@syncfusion/ej2-react-schedule": "^30.2.0",
+ "@syncfusion/ej2-react-splitbuttons": "^30.2.0",
"@syncfusion/ej2-splitbuttons": "^30.2.0",
"cldr-data": "^36.0.4",
"lucide-react": "^0.522.0",
diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json
index 0fdac74..df46a27 100644
--- a/dashboard/public/program-info.json
+++ b/dashboard/public/program-info.json
@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
- "version": "2025.1.0-alpha.9",
+ "version": "2025.1.0-alpha.10",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -30,6 +30,15 @@
"commitId": "8d1df7199cb7"
},
"changelog": [
+ {
+ "version": "2025.1.0-alpha.10",
+ "date": "2025-10-15",
+ "changes": [
+ "✨ UI: Benutzer-Menü oben rechts – DropDownButton mit Benutzername/Rolle; Einträge: ‘Profil’ und ‘Abmelden’.",
+ "🧩 Frontend: Syncfusion SplitButtons integriert (react-splitbuttons) und Vite-Konfiguration für Pre-Bundling ergänzt.",
+ "🐛 Fix: Import-Fehler ‘@syncfusion/ej2-react-splitbuttons’ – Anleitung in README hinzugefügt (optimizeDeps + Volume-Reset)."
+ ]
+ },
{
"version": "2025.1.0-alpha.9",
"date": "2025-10-14",
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 4dca5c9..fef4bc2 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -1,7 +1,9 @@
import React, { useState } from 'react';
-import { BrowserRouter as Router, Routes, Route, Link, Outlet } from 'react-router-dom';
+import { BrowserRouter as Router, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom';
import { SidebarComponent } from '@syncfusion/ej2-react-navigations';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
+import { DropDownButtonComponent } from '@syncfusion/ej2-react-splitbuttons';
+import type { MenuEventArgs } from '@syncfusion/ej2-splitbuttons';
import { TooltipComponent } from '@syncfusion/ej2-react-popups';
import logo from './assets/logo.png';
import './App.css';
@@ -43,10 +45,12 @@ import Infoscreens from './clients';
import Infoscreen_groups from './infoscreen_groups';
import Media from './media';
import Benutzer from './benutzer';
-import Einstellungen from './einstellungen';
+import Einstellungen from './settings';
import SetupMode from './SetupMode';
import Programminfo from './programminfo';
import Logout from './logout';
+import Login from './login';
+import { useAuth } from './useAuth';
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
// const ENV = import.meta.env.VITE_ENV || 'development';
@@ -55,6 +59,8 @@ const Layout: React.FC = () => {
const [version, setVersion] = useState('');
const [isCollapsed, setIsCollapsed] = useState(false);
let sidebarRef: SidebarComponent | null;
+ const { user } = useAuth();
+ const navigate = useNavigate();
React.useEffect(() => {
fetch('/program-info.json')
@@ -292,9 +298,46 @@ const Layout: React.FC = () => {
Infoscreen-Management
-
- [Organisationsname]
-
+
+
+ [Organisationsname]
+
+ {user && (
+
{
+ if (args.item.id === 'profile') {
+ navigate('/benutzer');
+ } else if (args.item.id === 'logout') {
+ navigate('/logout');
+ }
+ }}
+ cssClass="e-inherit"
+ >
+
+
+ {user.username}
+
+ {user.role}
+
+
+
+ )}
+
@@ -307,10 +350,24 @@ const Layout: React.FC = () => {
const App: React.FC = () => {
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
+ const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const { isAuthenticated, loading } = useAuth();
+ if (loading) return Lade ...
;
+ if (!isAuthenticated) return ;
+ return <>{children}>;
+ };
+
return (
- }>
+
+
+
+ }
+ >
} />
} />
} />
@@ -323,6 +380,7 @@ const App: React.FC = () => {
} />
} />
+ } />
);
diff --git a/dashboard/src/apiAuth.ts b/dashboard/src/apiAuth.ts
new file mode 100644
index 0000000..70bb336
--- /dev/null
+++ b/dashboard/src/apiAuth.ts
@@ -0,0 +1,162 @@
+/**
+ * Authentication API client for the dashboard.
+ *
+ * Provides functions to interact with auth endpoints including login,
+ * logout, and fetching current user information.
+ */
+
+export interface User {
+ id: number;
+ username: string;
+ role: 'user' | 'editor' | 'admin' | 'superadmin';
+ is_active: boolean;
+}
+
+export interface LoginRequest {
+ username: string;
+ password: string;
+}
+
+export interface LoginResponse {
+ message: string;
+ user: {
+ id: number;
+ username: string;
+ role: string;
+ };
+}
+
+export interface AuthCheckResponse {
+ authenticated: boolean;
+ role?: string;
+}
+
+/**
+ * Authenticate a user with username and password.
+ *
+ * @param username - The user's username
+ * @param password - The user's password
+ * @returns Promise
+ * @throws Error if login fails
+ */
+export async function login(username: string, password: string): Promise {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include', // Important for session cookies
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok || data.error) {
+ throw new Error(data.error || 'Login failed');
+ }
+
+ return data;
+}
+
+/**
+ * Log out the current user.
+ *
+ * @returns Promise
+ * @throws Error if logout fails
+ */
+export async function logout(): Promise {
+ const res = await fetch('/api/auth/logout', {
+ method: 'POST',
+ credentials: 'include',
+ });
+
+ const data = await res.json();
+
+ if (!res.ok || data.error) {
+ throw new Error(data.error || 'Logout failed');
+ }
+}
+
+/**
+ * Fetch the current authenticated user's information.
+ *
+ * @returns Promise
+ * @throws Error if not authenticated or request fails
+ */
+export async function fetchCurrentUser(): Promise {
+ const res = await fetch('/api/auth/me', {
+ method: 'GET',
+ credentials: 'include',
+ });
+
+ const data = await res.json();
+
+ if (!res.ok || data.error) {
+ throw new Error(data.error || 'Failed to fetch current user');
+ }
+
+ return data as User;
+}
+
+/**
+ * Quick check if user is authenticated (lighter than fetchCurrentUser).
+ *
+ * @returns Promise
+ */
+export async function checkAuth(): Promise {
+ const res = await fetch('/api/auth/check', {
+ method: 'GET',
+ credentials: 'include',
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error('Failed to check authentication status');
+ }
+
+ return data;
+}
+
+/**
+ * Helper function to check if a user has a specific role.
+ *
+ * @param user - The user object
+ * @param role - The role to check for
+ * @returns boolean
+ */
+export function hasRole(user: User | null, role: string): boolean {
+ if (!user) return false;
+ return user.role === role;
+}
+
+/**
+ * Helper function to check if a user has any of the specified roles.
+ *
+ * @param user - The user object
+ * @param roles - Array of roles to check for
+ * @returns boolean
+ */
+export function hasAnyRole(user: User | null, roles: string[]): boolean {
+ if (!user) return false;
+ return roles.includes(user.role);
+}
+
+/**
+ * Helper function to check if user is superadmin.
+ */
+export function isSuperadmin(user: User | null): boolean {
+ return hasRole(user, 'superadmin');
+}
+
+/**
+ * Helper function to check if user is admin or higher.
+ */
+export function isAdminOrHigher(user: User | null): boolean {
+ return hasAnyRole(user, ['admin', 'superadmin']);
+}
+
+/**
+ * Helper function to check if user is editor or higher.
+ */
+export function isEditorOrHigher(user: User | null): boolean {
+ return hasAnyRole(user, ['editor', 'admin', 'superadmin']);
+}
diff --git a/dashboard/src/login.tsx b/dashboard/src/login.tsx
new file mode 100644
index 0000000..8db23e9
--- /dev/null
+++ b/dashboard/src/login.tsx
@@ -0,0 +1,95 @@
+import React, { useState } from 'react';
+import { useAuth } from './useAuth';
+
+export default function Login() {
+ const { login, loading, error, logout } = useAuth();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [message, setMessage] = useState(null);
+ const isDev = import.meta.env.MODE !== 'production';
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setMessage(null);
+ try {
+ await login(username, password);
+ // Browser will stay on /login; App's route gate will redirect to '/'
+ setMessage('Login erfolgreich');
+ } catch (err) {
+ setMessage(err instanceof Error ? err.message : 'Login fehlgeschlagen');
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/dashboard/src/logout.tsx b/dashboard/src/logout.tsx
index 2aecbe1..6c8b7b7 100644
--- a/dashboard/src/logout.tsx
+++ b/dashboard/src/logout.tsx
@@ -1,12 +1,41 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from './useAuth';
-const Logout: React.FC = () => (
-
-
-
Abmeldung
-
Sie haben sich erfolgreich abgemeldet.
+const Logout: React.FC = () => {
+ const navigate = useNavigate();
+ const { logout } = useAuth();
+ const [error, setError] = useState
(null);
+
+ useEffect(() => {
+ let mounted = true;
+ (async () => {
+ try {
+ await logout();
+ } catch (err) {
+ if (mounted) {
+ const msg = err instanceof Error ? err.message : 'Logout fehlgeschlagen';
+ setError(msg);
+ }
+ } finally {
+ // Weiter zur Login-Seite, auch wenn Logout-Request fehlschlägt
+ navigate('/login', { replace: true });
+ }
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, [logout, navigate]);
+
+ return (
+
+
+
Abmeldung
+
{error ? `Hinweis: ${error}` : 'Sie werden abgemeldet …'}
+
Falls nichts passiert: Zur Login-Seite
+
-
-);
+ );
+};
export default Logout;
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx
index 9e115d7..6a71cc0 100644
--- a/dashboard/src/main.tsx
+++ b/dashboard/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
+import { AuthProvider } from './useAuth';
import { registerLicense } from '@syncfusion/ej2-base';
import '@syncfusion/ej2-base/styles/material3.css';
import '@syncfusion/ej2-navigations/styles/material3.css';
@@ -28,6 +29,8 @@ registerLicense(
createRoot(document.getElementById('root')!).render(
-
+
+
+
);
diff --git a/dashboard/src/einstellungen.tsx b/dashboard/src/settings.tsx
similarity index 100%
rename from dashboard/src/einstellungen.tsx
rename to dashboard/src/settings.tsx
diff --git a/dashboard/src/useAuth.tsx b/dashboard/src/useAuth.tsx
new file mode 100644
index 0000000..cbadb45
--- /dev/null
+++ b/dashboard/src/useAuth.tsx
@@ -0,0 +1,145 @@
+/**
+ * Auth context and hook for managing current user state.
+ *
+ * Provides a React context and custom hook to access and manage
+ * the current authenticated user throughout the application.
+ */
+
+import { createContext, useContext, useState, useEffect } from 'react';
+import type { ReactNode } from 'react';
+import { fetchCurrentUser, login as apiLogin, logout as apiLogout } from './apiAuth';
+import type { User } from './apiAuth';
+
+interface AuthContextType {
+ user: User | null;
+ loading: boolean;
+ error: string | null;
+ login: (username: string, password: string) => Promise
;
+ logout: () => Promise;
+ refreshUser: () => Promise;
+ isAuthenticated: boolean;
+}
+
+const AuthContext = createContext(undefined);
+
+interface AuthProviderProps {
+ children: ReactNode;
+}
+
+/**
+ * Auth provider component to wrap the application.
+ *
+ * Usage:
+ *
+ *
+ *
+ */
+export function AuthProvider({ children }: AuthProviderProps) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Fetch current user on mount
+ useEffect(() => {
+ refreshUser();
+ }, []);
+
+ const refreshUser = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const currentUser = await fetchCurrentUser();
+ setUser(currentUser);
+ } catch (err) {
+ // Not authenticated or error - this is okay
+ setUser(null);
+ // Only set error if it's not a 401 (not authenticated is expected)
+ if (err instanceof Error && !err.message.includes('Not authenticated')) {
+ setError(err.message);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const login = async (username: string, password: string) => {
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await apiLogin(username, password);
+ setUser(response.user as User);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Login failed';
+ setError(errorMessage);
+ throw err; // Re-throw so the caller can handle it
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const logout = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ await apiLogout();
+ setUser(null);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Logout failed';
+ setError(errorMessage);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const value: AuthContextType = {
+ user,
+ loading,
+ error,
+ login,
+ logout,
+ refreshUser,
+ isAuthenticated: user !== null,
+ };
+
+ return {children};
+}
+
+/**
+ * Custom hook to access auth context.
+ *
+ * Usage:
+ * const { user, login, logout, isAuthenticated } = useAuth();
+ *
+ * @returns AuthContextType
+ * @throws Error if used outside AuthProvider
+ */
+export function useAuth(): AuthContextType {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+}
+
+/**
+ * Convenience hook to get just the current user.
+ *
+ * Usage:
+ * const user = useCurrentUser();
+ */
+export function useCurrentUser(): User | null {
+ const { user } = useAuth();
+ return user;
+}
+
+/**
+ * Convenience hook to check if user is authenticated.
+ *
+ * Usage:
+ * const isAuthenticated = useIsAuthenticated();
+ */
+export function useIsAuthenticated(): boolean {
+ const { isAuthenticated } = useAuth();
+ return isAuthenticated;
+}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
index 6d59980..34d4a56 100644
--- a/dashboard/vite.config.ts
+++ b/dashboard/vite.config.ts
@@ -19,9 +19,11 @@ export default defineConfig({
include: [
'@syncfusion/ej2-react-navigations',
'@syncfusion/ej2-react-buttons',
+ '@syncfusion/ej2-react-splitbuttons',
'@syncfusion/ej2-base',
'@syncfusion/ej2-navigations',
'@syncfusion/ej2-buttons',
+ '@syncfusion/ej2-splitbuttons',
'@syncfusion/ej2-react-base',
],
// 🔧 NEU: Force dependency re-optimization
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index e208541..6485637 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -75,9 +75,12 @@ services:
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
DB_HOST: db
FLASK_ENV: production
+ FLASK_SECRET_KEY: ${FLASK_SECRET_KEY}
MQTT_BROKER_URL: mqtt://mqtt:1883
MQTT_USER: ${MQTT_USER}
MQTT_PASSWORD: ${MQTT_PASSWORD}
+ DEFAULT_SUPERADMIN_USERNAME: ${DEFAULT_SUPERADMIN_USERNAME:-superadmin}
+ DEFAULT_SUPERADMIN_PASSWORD: ${DEFAULT_SUPERADMIN_PASSWORD}
networks:
- infoscreen-net
healthcheck:
diff --git a/docker-compose.yml b/docker-compose.yml
index 565e7f4..0f84030 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,6 +18,10 @@ services:
environment:
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
- DB_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
+ - ENV=${ENV:-development}
+ - FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-dev-secret-key-change-in-production}
+ - DEFAULT_SUPERADMIN_USERNAME=${DEFAULT_SUPERADMIN_USERNAME:-superadmin}
+ - DEFAULT_SUPERADMIN_PASSWORD=${DEFAULT_SUPERADMIN_PASSWORD}
# 🔧 ENTFERNT: Volume-Mount ist nur für die Entwicklung
networks:
- infoscreen-net
diff --git a/models/models.py b/models/models.py
index 2c3c75f..99dd48d 100644
--- a/models/models.py
+++ b/models/models.py
@@ -10,6 +10,7 @@ Base = declarative_base()
class UserRole(enum.Enum):
user = "user"
+ editor = "editor"
admin = "admin"
superadmin = "superadmin"
diff --git a/server/alembic/versions/488ce87c28ae_merge_all_heads_before_user_role_.py b/server/alembic/versions/488ce87c28ae_merge_all_heads_before_user_role_.py
new file mode 100644
index 0000000..a6eeb7a
--- /dev/null
+++ b/server/alembic/versions/488ce87c28ae_merge_all_heads_before_user_role_.py
@@ -0,0 +1,28 @@
+"""Merge all heads before user role migration
+
+Revision ID: 488ce87c28ae
+Revises: 12ab34cd56ef, 15c357c0cf31, add_userrole_editor_and_column
+Create Date: 2025-10-15 05:46:17.984934
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '488ce87c28ae'
+down_revision: Union[str, None] = ('12ab34cd56ef', '15c357c0cf31', 'add_userrole_editor_and_column')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ pass
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ pass
diff --git a/server/alembic/versions/add_userrole_editor_and_column.py b/server/alembic/versions/add_userrole_editor_and_column.py
new file mode 100644
index 0000000..256b2b5
--- /dev/null
+++ b/server/alembic/versions/add_userrole_editor_and_column.py
@@ -0,0 +1,40 @@
+"""
+Add editor role to UserRole enum and ensure role column exists on users table
+"""
+from alembic import op
+import sqlalchemy as sa
+import enum
+
+# revision identifiers, used by Alembic.
+revision = 'add_userrole_editor_and_column'
+down_revision = None # Set this to the latest revision in your repo
+branch_labels = None
+depends_on = None
+
+# Define the new enum including 'editor'
+class userrole_enum(enum.Enum):
+ user = "user"
+ editor = "editor"
+ admin = "admin"
+ superadmin = "superadmin"
+
+def upgrade():
+ # MySQL: check if 'role' column exists
+ conn = op.get_bind()
+ insp = sa.inspect(conn)
+ columns = [col['name'] for col in insp.get_columns('users')]
+ if 'role' not in columns:
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.add_column(sa.Column('role', sa.Enum('user', 'editor', 'admin', 'superadmin', name='userrole'), nullable=False, server_default='user'))
+ else:
+ # If the column exists, alter the ENUM to add 'editor' if not present
+ # MySQL: ALTER TABLE users MODIFY COLUMN role ENUM(...)
+ conn.execute(sa.text(
+ "ALTER TABLE users MODIFY COLUMN role ENUM('user','editor','admin','superadmin') NOT NULL DEFAULT 'user'"
+ ))
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.drop_column('role')
+ # ### end Alembic commands ###
diff --git a/server/init_defaults.py b/server/init_defaults.py
index 18ec0f3..4b1be69 100644
--- a/server/init_defaults.py
+++ b/server/init_defaults.py
@@ -20,19 +20,25 @@ with engine.connect() as conn:
)
print("✅ Default-Gruppe mit id=1 angelegt.")
- # Admin-Benutzer anlegen, falls nicht vorhanden
- admin_user = os.getenv("DEFAULT_ADMIN_USERNAME", "infoscreen_admin")
- admin_pw = os.getenv("DEFAULT_ADMIN_PASSWORD", "Info_screen_admin25!")
+ # Superadmin-Benutzer anlegen, falls nicht vorhanden
+ admin_user = os.getenv("DEFAULT_SUPERADMIN_USERNAME", "superadmin")
+ admin_pw = os.getenv("DEFAULT_SUPERADMIN_PASSWORD")
+
+ if not admin_pw:
+ print("⚠️ DEFAULT_SUPERADMIN_PASSWORD nicht gesetzt. Superadmin wird nicht erstellt.")
+ else:
# Passwort hashen mit bcrypt
- hashed_pw = bcrypt.hashpw(admin_pw.encode(
- 'utf-8'), bcrypt.gensalt()).decode('utf-8')
- # Prüfen, ob User existiert
- result = conn.execute(text(
- "SELECT COUNT(*) FROM users WHERE username=:username"), {"username": admin_user})
- if result.scalar() == 0:
- # Rolle: 1 = Admin (ggf. anpassen je nach Modell)
- conn.execute(
- text("INSERT INTO users (username, password_hash, role, is_active) VALUES (:username, :password_hash, 1, 1)"),
- {"username": admin_user, "password_hash": hashed_pw}
- )
- print(f"✅ Admin-Benutzer '{admin_user}' angelegt.")
+ hashed_pw = bcrypt.hashpw(admin_pw.encode(
+ 'utf-8'), bcrypt.gensalt()).decode('utf-8')
+ # Prüfen, ob User existiert
+ result = conn.execute(text(
+ "SELECT COUNT(*) FROM users WHERE username=:username"), {"username": admin_user})
+ if result.scalar() == 0:
+ # Rolle: 'superadmin' gemäß UserRole enum
+ conn.execute(
+ text("INSERT INTO users (username, password_hash, role, is_active) VALUES (:username, :password_hash, 'superadmin', 1)"),
+ {"username": admin_user, "password_hash": hashed_pw}
+ )
+ print(f"✅ Superadmin-Benutzer '{admin_user}' angelegt.")
+ else:
+ print(f"ℹ️ Superadmin-Benutzer '{admin_user}' existiert bereits.")
diff --git a/server/permissions.py b/server/permissions.py
new file mode 100644
index 0000000..b2ccf63
--- /dev/null
+++ b/server/permissions.py
@@ -0,0 +1,176 @@
+"""
+Permission decorators for role-based access control.
+
+This module provides decorators to protect Flask routes based on user roles.
+"""
+
+from functools import wraps
+from flask import session, jsonify
+import os
+from models.models import UserRole
+
+
+def require_auth(f):
+ """
+ Require user to be authenticated.
+
+ Usage:
+ @app.route('/protected')
+ @require_auth
+ def protected_route():
+ return "You are logged in"
+ """
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ user_id = session.get('user_id')
+ if not user_id:
+ return jsonify({"error": "Authentication required"}), 401
+ return f(*args, **kwargs)
+ return decorated_function
+
+
+def require_role(*allowed_roles):
+ """
+ Require user to have one of the specified roles.
+
+ Args:
+ *allowed_roles: Variable number of role strings or UserRole enum values
+
+ Usage:
+ @app.route('/admin-only')
+ @require_role('admin', 'superadmin')
+ def admin_route():
+ return "Admin access"
+
+ # Or using enum:
+ @require_role(UserRole.admin, UserRole.superadmin)
+ def admin_route():
+ return "Admin access"
+ """
+ # Convert all roles to strings for comparison
+ allowed_role_strings = set()
+ for role in allowed_roles:
+ if isinstance(role, UserRole):
+ allowed_role_strings.add(role.value)
+ elif isinstance(role, str):
+ allowed_role_strings.add(role)
+ else:
+ raise ValueError(f"Invalid role type: {type(role)}")
+
+ def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ user_id = session.get('user_id')
+ user_role = session.get('role')
+
+ if not user_id or not user_role:
+ return jsonify({"error": "Authentication required"}), 401
+
+ # In development, allow superadmin to bypass all checks to prevent blocking
+ env = os.environ.get('ENV', 'production').lower()
+ if env in ('development', 'dev') and user_role == UserRole.superadmin.value:
+ return f(*args, **kwargs)
+
+ if user_role not in allowed_role_strings:
+ return jsonify({
+ "error": "Insufficient permissions",
+ "required_roles": list(allowed_role_strings),
+ "your_role": user_role
+ }), 403
+
+ return f(*args, **kwargs)
+ return decorated_function
+ return decorator
+
+
+def require_any_role(*allowed_roles):
+ """
+ Alias for require_role for better readability.
+ Require user to have ANY of the specified roles.
+
+ Usage:
+ @require_any_role('editor', 'admin', 'superadmin')
+ def edit_route():
+ return "Can edit"
+ """
+ return require_role(*allowed_roles)
+
+
+def require_all_roles(*required_roles):
+ """
+ Require user to have ALL of the specified roles.
+ Note: This is typically not needed since users only have one role,
+ but included for completeness.
+
+ Usage:
+ @require_all_roles('admin')
+ def strict_route():
+ return "Must have all roles"
+ """
+ # Convert all roles to strings
+ required_role_strings = set()
+ for role in required_roles:
+ if isinstance(role, UserRole):
+ required_role_strings.add(role.value)
+ elif isinstance(role, str):
+ required_role_strings.add(role)
+
+ def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ user_id = session.get('user_id')
+ user_role = session.get('role')
+
+ if not user_id or not user_role:
+ return jsonify({"error": "Authentication required"}), 401
+
+ # For single-role systems, check if user role is in required set
+ if user_role not in required_role_strings:
+ return jsonify({
+ "error": "Insufficient permissions",
+ "required_roles": list(required_role_strings),
+ "your_role": user_role
+ }), 403
+
+ return f(*args, **kwargs)
+ return decorated_function
+ return decorator
+
+
+def superadmin_only(f):
+ """
+ Convenience decorator for superadmin-only routes.
+
+ Usage:
+ @app.route('/critical-settings')
+ @superadmin_only
+ def critical_settings():
+ return "Superadmin only"
+ """
+ return require_role(UserRole.superadmin)(f)
+
+
+def admin_or_higher(f):
+ """
+ Convenience decorator for admin and superadmin routes.
+
+ Usage:
+ @app.route('/settings')
+ @admin_or_higher
+ def settings():
+ return "Admin or superadmin"
+ """
+ return require_role(UserRole.admin, UserRole.superadmin)(f)
+
+
+def editor_or_higher(f):
+ """
+ Convenience decorator for editor, admin, and superadmin routes.
+
+ Usage:
+ @app.route('/events', methods=['POST'])
+ @editor_or_higher
+ def create_event():
+ return "Can create events"
+ """
+ return require_role(UserRole.editor, UserRole.admin, UserRole.superadmin)(f)
diff --git a/server/routes/academic_periods.py b/server/routes/academic_periods.py
index 588ff0c..a8f3528 100644
--- a/server/routes/academic_periods.py
+++ b/server/routes/academic_periods.py
@@ -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')
diff --git a/server/routes/auth.py b/server/routes/auth.py
new file mode 100644
index 0000000..48c5ed5
--- /dev/null
+++ b/server/routes/auth.py
@@ -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()
diff --git a/server/routes/clients.py b/server/routes/clients.py
index f393a0e..e6bcc13 100644
--- a/server/routes/clients.py
+++ b/server/routes/clients.py
@@ -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("//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("/", 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("//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("/", methods=["DELETE"])
+@admin_or_higher
def delete_client(uuid):
session = Session()
client = session.query(Client).filter_by(uuid=uuid).first()
diff --git a/server/routes/conversions.py b/server/routes/conversions.py
index c6dc770..d0afdc9 100644
--- a/server/routes/conversions.py
+++ b/server/routes/conversions.py
@@ -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("//pdf", methods=["POST"])
+@editor_or_higher
def ensure_conversion(media_id: int):
session = Session()
try:
diff --git a/server/routes/event_exceptions.py b/server/routes/event_exceptions.py
index 4030bfb..ed0f212 100644
--- a/server/routes/event_exceptions.py
+++ b/server/routes/event_exceptions.py
@@ -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("/", 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("/", methods=["DELETE"])
+@editor_or_higher
def delete_exception(exc_id):
session = Session()
exc = session.query(EventException).filter_by(id=exc_id).first()
diff --git a/server/routes/eventmedia.py b/server/routes/eventmedia.py
index 80a508b..f990b9b 100644
--- a/server/routes/eventmedia.py
+++ b/server/routes/eventmedia.py
@@ -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('/', methods=['PUT'])
+@editor_or_higher
def update_media(media_id):
session = Session()
media = session.query(EventMedia).get(media_id)
diff --git a/server/routes/events.py b/server/routes/events.py
index a4b8bd8..35a67f4 100644
--- a/server/routes/events.py
+++ b/server/routes/events.py
@@ -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("/", 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("//occurrences/", 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("//occurrences//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("/", methods=["PUT"]) # update series or single event
+@editor_or_higher
def update_event(event_id):
data = request.json
session = Session()
diff --git a/server/routes/groups.py b/server/routes/groups.py
index a7d7403..79fe6a5 100644
--- a/server/routes/groups.py
+++ b/server/routes/groups.py
@@ -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("/", 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("/", 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/", 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/", methods=["PUT"])
+@admin_or_higher
def rename_group_by_name(old_name):
data = request.get_json()
new_name = data.get("newName")
diff --git a/server/routes/holidays.py b/server/routes/holidays.py
index 09b2b72..3e697df 100644
--- a/server/routes/holidays.py
+++ b/server/routes/holidays.py
@@ -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).
diff --git a/server/wsgi.py b/server/wsgi.py
index 9a77285..e2a8680 100644
--- a/server/wsgi.py
+++ b/server/wsgi.py
@@ -8,6 +8,7 @@ from server.routes.holidays import holidays_bp
from server.routes.academic_periods import academic_periods_bp
from server.routes.groups import groups_bp
from server.routes.clients import clients_bp
+from server.routes.auth import auth_bp
from server.database import Session, engine
from flask import Flask, jsonify, send_from_directory, request
import glob
@@ -17,8 +18,25 @@ sys.path.append('/workspace')
app = Flask(__name__)
+# Configure Flask session
+# In production, use a secure random key from environment variable
+app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')
+app.config['SESSION_COOKIE_HTTPONLY'] = True
+app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
+# In production, set to True if using HTTPS
+app.config['SESSION_COOKIE_SECURE'] = os.environ.get('ENV', 'development').lower() == 'production'
+# Session lifetime: longer in development for convenience
+from datetime import timedelta
+_env = os.environ.get('ENV', 'development').lower()
+if _env in ('development', 'dev'):
+ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30)
+else:
+ # Keep modest in production; can be tuned via env later
+ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=1)
+
# Blueprints importieren und registrieren
+app.register_blueprint(auth_bp)
app.register_blueprint(clients_bp)
app.register_blueprint(groups_bp)
app.register_blueprint(events_bp)
diff --git a/userrole-management.md b/userrole-management.md
new file mode 100644
index 0000000..930442c
--- /dev/null
+++ b/userrole-management.md
@@ -0,0 +1,139 @@
+# User Role Management Integration Guide
+
+This document outlines a step-by-step workpath to introduce user management and role-based access control (RBAC) into the infoscreen_2025 project. It is designed to minimize friction and allow incremental rollout.
+
+---
+
+## 1. Define Roles and Permissions
+
+- **Roles:**
+ - `superadmin`: Developer, can edit all settings (including critical/system settings), manage all users.
+ - `admin`: Organization employee, can edit all organization-relevant settings, manage users (except superadmin), manage groups/clients.
+ - `editor`: Can create, read, update, delete (CRUD) events and media.
+ - `user`: Can view events only.
+- **Permission Matrix:**
+ - See summary in the main design notes above for CRUD rights per area.
+
+---
+
+## 2. Extend Database Schema
+
+- Add a `role` column to the `users` table (Enum: superadmin, admin, editor, user; default: user).
+- Create an Alembic migration for this change.
+- Update SQLAlchemy models accordingly.
+
+---
+
+## 3. Seed Initial Superadmin
+
+- Update `server/init_defaults.py` to create a default superadmin user (with secure password, ideally from env or prompt).
+
+---
+
+## 4. Backend: Auth Context and Role Exposure
+
+- Ensure current user is determined per request (session or token-based).
+- Add `/api/me` endpoint to return current user's id, username, and role.
+
+---
+
+## 5. Backend: Permission Decorators
+
+- Implement decorators (e.g., `@require_role('admin')`, `@require_any_role(['editor','admin','superadmin'])`).
+- Apply to sensitive routes:
+ - User management (admin+)
+ - Program settings (admin/superadmin)
+ - Event settings (admin+)
+ - Event CRUD (editor+)
+
+---
+
+## 6. Frontend: Role Awareness and Gating
+
+- Add a `useCurrentUser()` hook or similar to fetch `/api/me` and store role in context.
+- Gate navigation and UI controls based on role:
+ - Hide or disable settings/actions not permitted for current role.
+ - Show/hide user management UI for admin+ only.
+
+---
+
+## 7. Frontend: User Management UI
+
+- Add a Users page (admin+):
+ - List users (GridComponent)
+ - Create/invite user (Dialog)
+ - Set/reset role (DropDownList, prevent superadmin assignment unless current is superadmin)
+ - Reset password (Dialog)
+
+---
+
+## 8. Rollout Strategy
+
+- **Stage 1:** Implement model, seed, and minimal enforcement (now)
+- **Stage 2:** Expand backend enforcement and frontend gating (before wider testing)
+- **Stage 3:** Polish UI, add audit logging if needed (before production)
+
+---
+
+## 9. Testing
+
+- Test each role for correct access and UI visibility.
+- Ensure superadmin cannot be demoted or deleted by non-superadmin.
+- Validate that critical settings are only editable by superadmin.
+
+---
+
+## 10. Optional: Audit Logging
+
+- For production, consider logging critical changes (role changes, user creation/deletion, settings changes) for traceability.
+
+---
+
+## References
+- See `models/models.py`, `server/routes/`, and `dashboard/src/` for integration points.
+- Syncfusion: Use GridComponent, Dialog, DropDownList for user management UI.
+
+---
+
+This guide is designed for incremental, low-friction integration. Adjust steps as needed for your workflow and deployment practices.
+
+---
+
+## Stage 1: Concrete Step-by-Step Checklist
+
+1. **Extend the User Model**
+ - Add a `role` column to the `users` table in your SQLAlchemy model (`models/models.py`).
+ - Use an Enum for roles: `superadmin`, `admin`, `editor`, `user` (default: `user`).
+ - Create an Alembic migration to add this column to the database.
+
+2. **Seed a Superadmin User** ✅
+ - Update `server/init_defaults.py` to create a default superadmin user with a secure password (from environment variable).
+ - Ensure this user is only created if not already present.
+ - **Status:** Completed. See `server/init_defaults.py` - requires `DEFAULT_SUPERADMIN_PASSWORD` environment variable.
+
+ 3. **Expose Current User Role** ✅
+ - Add a `/api/me` endpoint (e.g., in `server/routes/auth.py`) that returns the current user's id, username, and role.
+ - Ensure the frontend can fetch and store this information on login or page load.
+ - **Status:** Completed. See:
+ - Backend: `server/routes/auth.py` (login, logout, /me, /check endpoints)
+ - Frontend: `dashboard/src/apiAuth.ts` and `dashboard/src/useAuth.tsx`
+ - Permissions: `server/permissions.py` (decorators for route protection)
+
+4. **Implement Minimal Role Enforcement** ✅
+ - Decorators added in `server/permissions.py`: `require_auth`, `require_role`, `editor_or_higher`, `admin_or_higher`, `superadmin_only`.
+ - Applied to sensitive endpoints:
+ - Groups (admin+): create, update, delete, rename (`server/routes/groups.py`)
+ - Clients (admin+): sync-all-groups, set description, bulk group update, patch, restart, delete (`server/routes/clients.py`)
+ - Academic periods (admin+): set active (`server/routes/academic_periods.py`)
+ - Events (editor+): create, update, delete, delete occurrence, detach occurrence (`server/routes/events.py`)
+ - Event media (editor+): filemanager operations/upload, metadata update (`server/routes/eventmedia.py`)
+ - Event exceptions (editor+): create, update, delete (`server/routes/event_exceptions.py`)
+ - Conversions (editor+): ensure conversion (`server/routes/conversions.py`)
+ - Superadmin dev bypass: In development (`ENV=development`), `superadmin` bypasses checks for unblocking.
+
+5. **Test the Flow**
+ - Confirm that the superadmin can access all protected endpoints.
+ - Confirm that users with other roles are denied access to protected endpoints.
+ - Ensure the frontend can correctly gate UI elements based on the current user's role.
+
+---