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. + +---