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

Dashboard

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

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

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

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

View File

@@ -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

View File

@@ -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

264
AUTH_QUICKREF.md Normal file
View File

@@ -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 <div>Loading...</div>;
if (!isAuthenticated) {
return <button onClick={() => login('user', 'pass')}>Login</button>;
}
return (
<div>
<p>Welcome {user?.username}</p>
<p>Role: {user?.role}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
```
### Conditional Rendering
```typescript
import { useCurrentUser } from './useAuth';
import { isAdminOrHigher, isEditorOrHigher } from './apiAuth';
function Navigation() {
const user = useCurrentUser();
return (
<nav>
<a href="/">Home</a>
{/* Show for all authenticated users */}
{user && <a href="/events">Events</a>}
{/* Show for editor+ */}
{isEditorOrHigher(user) && (
<a href="/events/new">Create Event</a>
)}
{/* Show for admin+ */}
{isAdminOrHigher(user) && (
<a href="/admin">Admin Panel</a>
)}
</nav>
);
}
```
### 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 <div>Loading...</div>;
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return <>{children}</>;
}
// Usage in routes:
<Route path="/admin" element={
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
} />
```
## 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

522
AUTH_SYSTEM.md Normal file
View File

@@ -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 <div>Loading...</div>;
if (!isAuthenticated) {
return <LoginForm onLogin={login} />;
}
return <div>Welcome {user.username}!</div>;
}
```
## 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 (
<AuthProvider>
{/* Your app components */}
</AuthProvider>
);
}
```
## 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 (
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/events">Events</a>
{isEditorOrHigher(user) && (
<a href="/events/new">Create Event</a>
)}
{isAdminOrHigher(user) && (
<>
<a href="/settings">Settings</a>
<a href="/users">Manage Users</a>
<a href="/groups">Manage Groups</a>
</>
)}
</nav>
);
}
```
### 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 (
<form onSubmit={handleSubmit}>
<h1>Login</h1>
{error && <div className="error">{error}</div>}
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
```
## 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', '<bcrypt_hash>', '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/

View File

@@ -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) │ │
│ └─────────────────┘ │
┌───────────────┐ ┌──────────────────────────┐ ┌───────────────┐
│ Dashboard │◄──────►│ API Server ◄──────►│ Worker │
│ (React/Vite) │ │ (Flask) │ │ (Conversions)
└───────────────┘ └──────────────────────────┘ └───────────────┘
│ │
└────────────────────┬───────────────────────────
┌─────────────────┐
│ MQTT Broker
│ (Mosquitto) │
└─────────────────┘
┌────────────────────┼────────────────────┐
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
Scheduler Worker Infoscreen
│ (Events) │ (Conversions) Clients │
└─────────────────┘ └─────────────────┘ └─────────────────┘
───────────────┐ │
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 <project>_dashboard-node-modules <project>_dashboard-vite-cache || true
docker compose up -d --build dashboard
```
```bash
# Test MQTT broker
mosquitto_pub -h localhost -t test -m "hello"

159
SUPERADMIN_SETUP.md Normal file
View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 = () => {
<span className="text-2xl font-bold mr-8" style={{ color: '#78591c' }}>
Infoscreen-Management
</span>
<span className="ml-auto text-lg font-medium" style={{ color: '#78591c' }}>
<div style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 16 }}>
<span className="text-lg font-medium" style={{ color: '#78591c' }}>
[Organisationsname]
</span>
{user && (
<DropDownButtonComponent
items={[
{ text: 'Profil', id: 'profile', iconCss: 'e-icons e-user' },
{ separator: true },
{ text: 'Abmelden', id: 'logout', iconCss: 'e-icons e-logout' },
]}
select={(args: MenuEventArgs) => {
if (args.item.id === 'profile') {
navigate('/benutzer');
} else if (args.item.id === 'logout') {
navigate('/logout');
}
}}
cssClass="e-inherit"
>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<User size={18} />
<span style={{ fontWeight: 600 }}>{user.username}</span>
<span
style={{
fontSize: '0.8rem',
textTransform: 'uppercase',
opacity: 0.85,
border: '1px solid rgba(120, 89, 28, 0.25)',
borderRadius: 6,
padding: '2px 6px',
backgroundColor: 'rgba(255, 255, 255, 0.6)',
}}
>
{user.role}
</span>
</div>
</DropDownButtonComponent>
)}
</div>
</header>
<main className="page-content">
<Outlet />
@@ -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 <div style={{ padding: 24 }}>Lade ...</div>;
if (!isAuthenticated) return <Login />;
return <>{children}</>;
};
return (
<ToastProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} />
@@ -323,6 +380,7 @@ const App: React.FC = () => {
<Route path="programminfo" element={<Programminfo />} />
</Route>
<Route path="/logout" element={<Logout />} />
<Route path="/login" element={<Login />} />
</Routes>
</ToastProvider>
);

162
dashboard/src/apiAuth.ts Normal file
View File

@@ -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<LoginResponse>
* @throws Error if login fails
*/
export async function login(username: string, password: string): Promise<LoginResponse> {
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<void>
* @throws Error if logout fails
*/
export async function logout(): Promise<void> {
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<User>
* @throws Error if not authenticated or request fails
*/
export async function fetchCurrentUser(): Promise<User> {
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<AuthCheckResponse>
*/
export async function checkAuth(): Promise<AuthCheckResponse> {
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']);
}

95
dashboard/src/login.tsx Normal file
View File

@@ -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<string | null>(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 (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<form onSubmit={handleSubmit} style={{ width: 360, padding: 24, border: '1px solid #ddd', borderRadius: 8, background: '#fff' }}>
<h2 style={{ marginTop: 0 }}>Anmeldung</h2>
{message && <div style={{ color: message.includes('erfolgreich') ? 'green' : 'crimson', marginBottom: 12 }}>{message}</div>}
{error && <div style={{ color: 'crimson', marginBottom: 12 }}>{error}</div>}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>Benutzername</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
style={{ width: '100%', padding: 8 }}
autoFocus
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{ width: '100%', padding: 8 }}
/>
</div>
<button type="submit" disabled={loading} style={{ width: '100%', padding: 10 }}>
{loading ? 'Anmelden ...' : 'Anmelden'}
</button>
{isDev && (
<button
type="button"
onClick={async () => {
setMessage(null);
try {
const res = await fetch('/api/auth/dev-login-superadmin', {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Dev-Login fehlgeschlagen');
setMessage('Dev-Login erfolgreich (Superadmin)');
// Refresh the page/state; the RequireAuth will render the app
window.location.href = '/';
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Dev-Login fehlgeschlagen');
}
}}
disabled={loading}
style={{ width: '100%', padding: 10, marginTop: 10 }}
>
Dev-Login (Superadmin)
</button>
)}
<button
type="button"
onClick={async () => {
try {
await logout();
setMessage('Abgemeldet.');
} catch {
// ignore
}
}}
style={{ width: '100%', padding: 10, marginTop: 10, background: '#f5f5f5' }}
>
Abmelden & zurück zur Anmeldung
</button>
</form>
</div>
);
}

View File

@@ -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 = () => (
const Logout: React.FC = () => {
const navigate = useNavigate();
const { logout } = useAuth();
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">Abmeldung</h2>
<p>Sie haben sich erfolgreich abgemeldet.</p>
<p>{error ? `Hinweis: ${error}` : 'Sie werden abgemeldet …'}</p>
<p style={{ marginTop: 16 }}>Falls nichts passiert: <a href="/login">Zur Login-Seite</a></p>
</div>
</div>
);
);
};
export default Logout;

View File

@@ -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(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>
);

145
dashboard/src/useAuth.tsx Normal file
View File

@@ -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<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
/**
* Auth provider component to wrap the application.
*
* Usage:
* <AuthProvider>
* <App />
* </AuthProvider>
*/
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* 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;
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -10,6 +10,7 @@ Base = declarative_base()
class UserRole(enum.Enum):
user = "user"
editor = "editor"
admin = "admin"
superadmin = "superadmin"

View File

@@ -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

View File

@@ -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 ###

View File

@@ -20,9 +20,13 @@ 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')
@@ -30,9 +34,11 @@ with engine.connect() as conn:
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)
# Rolle: 'superadmin' gemäß UserRole enum
conn.execute(
text("INSERT INTO users (username, password_hash, role, is_active) VALUES (:username, :password_hash, 1, 1)"),
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"Admin-Benutzer '{admin_user}' angelegt.")
print(f"Superadmin-Benutzer '{admin_user}' angelegt.")
else:
print(f" Superadmin-Benutzer '{admin_user}' existiert bereits.")

176
server/permissions.py Normal file
View File

@@ -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)

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

139
userrole-management.md Normal file
View File

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