From a7df3c2708c81d7fc0b03f347f799608fbf3dade Mon Sep 17 00:00:00 2001 From: RobbStarkAustria Date: Wed, 15 Oct 2025 16:33:35 +0000 Subject: [PATCH] feat(dashboard): header user dropdown (Syncfusion) + proper logout; docs: clarify architecture; build: add splitbuttons; bump alpha.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 12 +- .github/copilot-instructions.md | 10 + AUTH_QUICKREF.md | 264 +++++++++ AUTH_SYSTEM.md | 522 ++++++++++++++++++ README.md | 72 ++- SUPERADMIN_SETUP.md | 159 ++++++ dashboard/package-lock.json | 12 + dashboard/package.json | 1 + dashboard/public/program-info.json | 11 +- dashboard/src/App.tsx | 70 ++- dashboard/src/apiAuth.ts | 162 ++++++ dashboard/src/login.tsx | 95 ++++ dashboard/src/logout.tsx | 45 +- dashboard/src/main.tsx | 5 +- .../src/{einstellungen.tsx => settings.tsx} | 0 dashboard/src/useAuth.tsx | 145 +++++ dashboard/vite.config.ts | 2 + docker-compose.prod.yml | 3 + docker-compose.yml | 4 + models/models.py | 1 + ...c28ae_merge_all_heads_before_user_role_.py | 28 + .../add_userrole_editor_and_column.py | 40 ++ server/init_defaults.py | 36 +- server/permissions.py | 176 ++++++ server/routes/academic_periods.py | 2 + server/routes/auth.py | 210 +++++++ server/routes/clients.py | 7 + server/routes/conversions.py | 2 + server/routes/event_exceptions.py | 4 + server/routes/eventmedia.py | 4 + server/routes/events.py | 7 +- server/routes/groups.py | 6 + server/routes/holidays.py | 2 + server/wsgi.py | 18 + userrole-management.md | 139 +++++ 35 files changed, 2217 insertions(+), 59 deletions(-) create mode 100644 AUTH_QUICKREF.md create mode 100644 AUTH_SYSTEM.md create mode 100644 SUPERADMIN_SETUP.md create mode 100644 dashboard/src/apiAuth.ts create mode 100644 dashboard/src/login.tsx rename dashboard/src/{einstellungen.tsx => settings.tsx} (100%) create mode 100644 dashboard/src/useAuth.tsx create mode 100644 server/alembic/versions/488ce87c28ae_merge_all_heads_before_user_role_.py create mode 100644 server/alembic/versions/add_userrole_editor_and_column.py create mode 100644 server/permissions.py create mode 100644 server/routes/auth.py create mode 100644 userrole-management.md 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 ( +
+

Login

+ {error &&
{error}
} + + setUsername(e.target.value)} + disabled={loading} + /> + + setPassword(e.target.value)} + disabled={loading} + /> + + +
+ ); +} +``` + +## 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 ( +
+
+

Anmeldung

+ {message &&
{message}
} + {error &&
{error}
} +
+ + setUsername(e.target.value)} + disabled={loading} + style={{ width: '100%', padding: 8 }} + autoFocus + /> +
+
+ + setPassword(e.target.value)} + disabled={loading} + style={{ width: '100%', padding: 8 }} + /> +
+ + {isDev && ( + + )} + +
+
+ ); +} 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. + +---