6 Commits

Author SHA1 Message Date
RobbStarkAustria
3487d33a2f feat: improve scheduler recurrence, DB config, and docs
- Broaden scheduler query window to next N days for proper recurring event expansion (scheduler.py)
- Update DB connection logic for consistent .env loading and fallback (database.py)
- Harden timezone handling and logging in scheduler and DB utils
- Stop auto-deactivating recurring events before recurrence_end (API/events)
- Update documentation to reflect new scheduler, API, and logging behavior
2025-10-18 06:18:06 +00:00
RobbStarkAustria
150937f2e2 docs(settings): Update README + Copilot instructions; bump Program Info to 2025.1.0-alpha.11
README: Add System Settings API endpoints; describe new tabbed Settings layout with role gating; add Vite dev proxy tip to use relative /api paths.
Copilot instructions: Note SystemSetting key–value store in data model; document system_settings.py (CRUD + supplement-table convenience endpoint); reference apiSystemSettings.ts; note defaults seeding via init_defaults.py.
Program Info: Bump version to 2025.1.0-alpha.11; changelog explicitly tied to the Settings page (Events tab: supplement-table URL moved; Academic Calendar: set active period; proxy note); README docs mention.
No functional changes to API or UI code in this commit; documentation and program info only.
2025-10-16 19:15:55 +00:00
RobbStarkAustria
7b38b49598 rename benutzer to users
add role management to media page
2025-10-16 17:57:06 +00:00
RobbStarkAustria
a7df3c2708 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.
2025-10-15 16:33:35 +00:00
RobbStarkAustria
8676370fe2 docs: clarify event deletion flows and dialog handling for all event types
- Documented unified deletion process for single, single-in-series, and recurring series events
- Explained custom dialog interception of Syncfusion RecurrenceAlert and DeleteAlert
- Updated both README.md and .github/copilot-instructions.md to match current frontend logic
2025-10-14 19:10:38 +00:00
RobbStarkAustria
5f0972c79c Merge branch 'recurring_events_scheduler' 2025-10-14 05:55:12 +00:00
48 changed files with 3889 additions and 871 deletions

View File

@@ -4,6 +4,11 @@
# General # General
ENV=development 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) # Database (used if DB_CONN not provided)
DB_USER=your_user DB_USER=your_user
DB_PASSWORD=your_password DB_PASSWORD=your_password
@@ -24,13 +29,17 @@ MQTT_KEEPALIVE=60
# VITE_API_URL=https://your.api.example.com/api # VITE_API_URL=https://your.api.example.com/api
# Groups alive windows (seconds) # Groups alive windows (seconds)
HEARTBEAT_GRACE_PERIOD_DEV=15 # Clients send heartbeats every ~65s. Allow 2 missed heartbeats + safety margin
HEARTBEAT_GRACE_PERIOD_PROD=180 # Dev: 65s * 2 + 50s margin = 180s
# Prod: 65s * 2 + 40s margin = 170s
HEARTBEAT_GRACE_PERIOD_DEV=180
HEARTBEAT_GRACE_PERIOD_PROD=170
# Scheduler # Scheduler
# Optional: force periodic republish even without changes # Optional: force periodic republish even without changes
# REFRESH_SECONDS=0 # REFRESH_SECONDS=0
# Default admin bootstrap (server/init_defaults.py) # Default superadmin bootstrap (server/init_defaults.py)
DEFAULT_ADMIN_USERNAME=infoscreen_admin # REQUIRED: Must be set for superadmin creation
DEFAULT_ADMIN_PASSWORD=Info_screen_admin25! DEFAULT_SUPERADMIN_USERNAME=superadmin
DEFAULT_SUPERADMIN_PASSWORD=your_secure_password_here

View File

@@ -12,13 +12,13 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Dashboard: React + Vite in `dashboard/`, dev on :5173, served via Nginx in prod. - Dashboard: React + Vite in `dashboard/`, dev on :5173, served via Nginx in prod.
- MQTT broker: Eclipse Mosquitto, config in `mosquitto/config/mosquitto.conf`. - MQTT broker: Eclipse Mosquitto, config in `mosquitto/config/mosquitto.conf`.
- Listener: MQTT consumer handling discovery + heartbeats in `listener/listener.py`. - Listener: MQTT consumer handling discovery + heartbeats in `listener/listener.py`.
- Scheduler: Publishes active events (per group) to MQTT retained topics in `scheduler/scheduler.py`. - Scheduler: Publishes active events (per group) to MQTT retained topics in `scheduler/scheduler.py`. Scheduler now queries a future window (default: 7 days), expands recurring events using RFC 5545 rules, applies event exceptions, and publishes all valid occurrences. Logging is concise; conversion lookups are cached and logged only once per media.
- Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`). - Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`).
## Service boundaries & data flow ## Service boundaries & data flow
- Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services. - Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services.
- API builds its engine in `server/database.py` (loads `.env` only in development). - API builds its engine in `server/database.py` (loads `.env` only in development).
- Scheduler loads `DB_CONN` in `scheduler/db_utils.py`. - Scheduler loads `DB_CONN` in `scheduler/db_utils.py`. Recurring events are expanded for the next 7 days, and event exceptions (skipped dates, detached occurrences) are respected. Only recurring events with recurrence_end in the future remain active.
- Listener also creates its own engine for writes to `clients`. - Listener also creates its own engine for writes to `clients`.
- MQTT topics (paho-mqtt v2, use Callback API v2): - MQTT topics (paho-mqtt v2, use Callback API v2):
- Discovery: `infoscreen/discovery` (JSON includes `uuid`, hw/ip data). ACK to `infoscreen/{uuid}/discovery_ack`. See `listener/listener.py`. - Discovery: `infoscreen/discovery` (JSON includes `uuid`, hw/ip data). ACK to `infoscreen/{uuid}/discovery_ack`. See `listener/listener.py`.
@@ -38,6 +38,7 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
## Data model highlights (see `models/models.py`) ## Data model highlights (see `models/models.py`)
- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester). - Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester).
- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`. - Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`.
- System settings: `system_settings` keyvalue store via `SystemSetting` for global configuration (e.g., WebUntis/Vertretungsplan supplement-table). Managed through routes in `server/routes/system_settings.py`.
- Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility). - Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility).
- Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events). - Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events).
@@ -52,8 +53,9 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Examples: - Examples:
- Clients: `server/routes/clients.py` includes bulk group updates and MQTT sync (`publish_multiple_client_groups`). - Clients: `server/routes/clients.py` includes bulk group updates and MQTT sync (`publish_multiple_client_groups`).
- Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. - Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`.
- Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. - Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. Recurring events are only deactivated after their recurrence_end (UNTIL); non-recurring events deactivate after their end time. Event exceptions are respected and rendered in scheduler output.
- Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`. - Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`.
- System settings: `server/routes/system_settings.py` exposes keyvalue CRUD (`/api/system-settings`) and a convenience endpoint for WebUntis/Vertretungsplan supplement-table: `GET/POST /api/system-settings/supplement-table` (admin+).
- Academic periods: `server/routes/academic_periods.py` exposes: - Academic periods: `server/routes/academic_periods.py` exposes:
- `GET /api/academic_periods` — list all periods - `GET /api/academic_periods` — list all periods
- `GET /api/academic_periods/active` — currently active period - `GET /api/academic_periods/active` — currently active period
@@ -70,10 +72,15 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check) - Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
- Recurrence & holidays (latest): - Recurrence & holidays (latest):
- Backend stores holiday skips in `EventException` and emits `RecurrenceException` (EXDATE) for master events in `GET /api/events`. EXDATE timestamps match each occurrence start time (UTC) so Syncfusion excludes instances on holidays reliably. - Backend stores holiday skips in `EventException` and emits `RecurrenceException` (EXDATE) for master events in `GET /api/events`. EXDATE tokens are formatted in RFC 5545 compact form (`yyyyMMddTHHmmssZ`) and correspond to each occurrence start time (UTC). Syncfusion uses these to exclude holiday instances reliably.
- Frontend manually expands recurring events due to Syncfusion EXDATE handling bugs. Daily/Weekly recurrence patterns are expanded client-side with proper EXDATE filtering and DST timezone tolerance (2-hour window). - Frontend lets Syncfusion handle all recurrence patterns natively (no client-side expansion). Scheduler field mappings include `recurrenceID`, `recurrenceRule`, and `recurrenceException` so series and edited occurrences are recognized correctly.
- Single occurrence editing: Users can detach individual occurrences from recurring series via confirmation dialog. The detach operation creates `EventException` records, generates EXDATE entries, and creates standalone events without affecting the master series. - Event deletion: All event types (single, single-in-series, entire series) are handled with custom dialogs. The frontend intercepts Syncfusion's built-in RecurrenceAlert and DeleteAlert popups to provide a unified, user-friendly deletion flow:
- UI: Events with `SkipHolidays` render a TentTree icon directly after the main event icon in the scheduler event template. Icon color: black. - Single (non-recurring) event: deleted directly after confirmation.
- Single occurrence of a recurring series: user can delete just that instance.
- Entire recurring series: user can delete all occurrences after a final custom confirmation dialog.
- Detached occurrences (edited/broken out): treated as single events.
- Single occurrence editing: Users can detach individual occurrences from recurring series. The frontend hooks `actionComplete`/`onActionCompleted` with `requestType='eventChanged'` to persist changes: it calls `POST /api/events/<id>/occurrences/<date>/detach` for single-occurrence edits and `PUT /api/events/<id>` for series or single events as appropriate. The backend creates `EventException` and a standalone `Event` without modifying the master beyond EXDATEs.
- UI: Events with `SkipHolidays` render a TentTree icon next to the main event icon. The custom recurrence icon in the header was removed; rely on Syncfusions native lower-right recurrence badge.
- Program info page (`dashboard/src/programminfo.tsx`): - Program info page (`dashboard/src/programminfo.tsx`):
- Loads data from `dashboard/public/program-info.json` (app name, version, build info, tech stack, changelog). - Loads data from `dashboard/public/program-info.json` (app name, version, build info, tech stack, changelog).
@@ -84,6 +91,35 @@ 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. - 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. - 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`.
- Settings page (`dashboard/src/settings.tsx`):
- Structure: Syncfusion TabComponent with role-gated tabs
- 📅 Academic Calendar (all users)
- School Holidays: CSV/TXT import and list
- Academic Periods: select and set active period (uses `/api/academic_periods` routes)
- 🖥️ Display & Clients (admin+)
- Default Settings: placeholders for heartbeat, screenshots, defaults
- Client Configuration: quick links to Clients and Groups pages
- 🎬 Media & Files (admin+)
- Upload Settings: placeholders for limits and types
- Conversion Status: placeholder for conversions overview
- 🗓️ Events (admin+)
- WebUntis / Vertretungsplan: system-wide supplement table URL with enable/disable, save, and preview; persists via `/api/system-settings/supplement-table`
- Other event types (presentation, website, video, message, other): placeholders for defaults
- ⚙️ System (superadmin)
- Organization Info and Advanced Configuration placeholders
- Role gating: Admin/Superadmin tabs are hidden if the user lacks permission; System is superadmin-only
- API clients use relative `/api/...` URLs so Vite dev proxy handles requests without CORS issues. The settings UI calls are centralized in `dashboard/src/apiSystemSettings.ts`.
- 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. 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 ## Local development
@@ -94,6 +130,7 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
- Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`. - Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`.
- Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn. - Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn.
- Local dev: prefer `python server/initialize_database.py` for one-shot setup (migrations + defaults + academic periods). - Local dev: prefer `python server/initialize_database.py` for one-shot setup (migrations + defaults + academic periods).
- Defaults: `server/init_defaults.py` seeds initial system settings like `supplement_table_url` and `supplement_table_enabled` if missing.
- `server/init_academic_periods.py` remains available to (re)seed school years. - `server/init_academic_periods.py` remains available to (re)seed school years.
## Production ## Production
@@ -106,11 +143,12 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
- ENV — `development` or `production`; in development, `server/database.py` loads `.env`. - ENV — `development` or `production`; in development, `server/database.py` loads `.env`.
- MQTT_BROKER_HOST, MQTT_BROKER_PORT — Defaults `mqtt` and `1883`; MQTT_USER/MQTT_PASSWORD optional (dev often anonymous per Mosquitto config). - MQTT_BROKER_HOST, MQTT_BROKER_PORT — Defaults `mqtt` and `1883`; MQTT_USER/MQTT_PASSWORD optional (dev often anonymous per Mosquitto config).
- VITE_API_URL — Dashboard build-time base URL (prod); in dev the Vite proxy serves `/api` to `server:8000`. - VITE_API_URL — Dashboard build-time base URL (prod); in dev the Vite proxy serves `/api` to `server:8000`.
- HEARTBEAT_GRACE_PERIOD_DEV / HEARTBEAT_GRACE_PERIOD_PROD — Groups alive window (defaults ~15s dev / 180s prod). - HEARTBEAT_GRACE_PERIOD_DEV / HEARTBEAT_GRACE_PERIOD_PROD — Groups "alive" window (defaults 180s dev / 170s prod). Clients send heartbeats every ~65s; grace periods allow 2 missed heartbeats plus safety margin.
- REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh. - REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh.
## Conventions & gotchas ## Conventions & gotchas
- Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`). - Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`).
- Scheduler queries a future window (default: 7 days) and expands recurring events using RFC 5545 rules. Event exceptions are respected. Logging is concise and conversion lookups are cached.
- Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id). - Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id).
- In-container DB host is `db`; do not use `localhost` inside services. - In-container DB host is `db`; do not use `localhost` inside services.
- No separate dev vs prod secret conventions: use the same env var keys across environments (e.g., `DB_CONN`, `MQTT_USER`, `MQTT_PASSWORD`). - No separate dev vs prod secret conventions: use the same env var keys across environments (e.g., `DB_CONN`, `MQTT_USER`, `MQTT_PASSWORD`).
@@ -124,10 +162,10 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
- Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward. - Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward.
### Recurrence & holidays: conventions ### Recurrence & holidays: conventions
- Do not pre-expand recurrences on the backend. Always send master event with `RecurrenceRule` + `RecurrenceException`. - Do not pre-expand recurrences on the backend. Always send master events with `RecurrenceRule` + `RecurrenceException`.
- Ensure EXDATE tokens include the occurrence start time (HH:mm:ss) in UTC to match manual expansion logic. - Ensure EXDATE tokens are RFC 5545 timestamps (`yyyyMMddTHHmmssZ`) matching the occurrence start time (UTC) so Syncfusion can exclude them natively.
- When `skip_holidays` or recurrence changes, regenerate `EventException` rows so `RecurrenceException` stays in sync. - When `skip_holidays` or recurrence changes, regenerate `EventException` rows so `RecurrenceException` stays in sync.
- Single occurrence detach: Use `POST /api/events/<id>/occurrences/<date>/detach` to create standalone events and add EXDATE entries without modifying master events. - Single occurrence detach: Use `POST /api/events/<id>/occurrences/<date>/detach` to create standalone events and add EXDATE entries without modifying master events. The frontend persists edits via `actionComplete` (`requestType='eventChanged'`).
## Quick examples ## Quick examples
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`. - Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.

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/

122
README.md
View File

@@ -11,35 +11,42 @@ A comprehensive multi-service digital signage solution for educational instituti
## 🏗️ Architecture Overview ## 🏗️ Architecture Overview
``` ```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ ┌──────────────────────────┐ ┌───────────────┐
│ Dashboard API Server Listener │ │ Dashboard │◄──────►│ API Server ◄──────►│ Worker │
(React/Vite) │◄──►│ (Flask) │◄──►│ (MQTT Client) │ (React/Vite) │ │ (Flask) │ │ (Conversions)
└─────────────────┘ └─────────────────┘ └─────────────────┘ └───────────────┘ └──────────────────────────┘ └───────────────┘
│ ▼ │
│ ┌─────────────────┐ │
│ │ MariaDB │ │
│ │ (Database) │ │
│ └─────────────────┘ │
│ │ │ │
└────────────────────┬─────────────────────────── ───────────────┐ │
MariaDB
┌─────────────────┐ (Database)
│ MQTT Broker └───────────────┘
│ (Mosquitto) │ │ 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)
└─────────────────┘ └─────────────────┘
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Scheduler │ │ Worker │ │ Infoscreen │
│ (Events) │ │ (Conversions) │ │ Clients │
└─────────────────┘ └─────────────────┘ └─────────────────┘
``` ```
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 ## 🌟 Key Features
### 📊 **Dashboard Management**
- Modern React-based web interface with Syncfusion components - Modern React-based web interface with Syncfusion components
- Real-time client monitoring and group management - Real-time client monitoring and group management
- Event scheduling with academic period support - Event scheduling with academic period support
@@ -47,13 +54,19 @@ A comprehensive multi-service digital signage solution for educational instituti
- Holiday calendar integration - Holiday calendar integration
- Visual indicators: TentTree icon next to the main event icon marks events that skip holidays (icon color: black) - Visual indicators: TentTree icon next to the main event icon marks events that skip holidays (icon color: black)
- **Event Deletion**: All event types (single, single-in-series, entire series) are handled with custom dialogs. The frontend intercepts Syncfusion's built-in RecurrenceAlert and DeleteAlert popups to provide a unified, user-friendly deletion flow:
- Single (non-recurring) event: deleted directly after confirmation.
- Single occurrence of a recurring series: user can delete just that instance.
- Entire recurring series: user can delete all occurrences after a final custom confirmation dialog.
- Detached occurrences (edited/broken out): treated as single events.
### 🎯 **Event System** ### 🎯 **Event System**
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg - **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
- **Websites**: URL-based content display - **Websites**: URL-based content display
- **Videos**: Media file streaming - **Videos**: Media file streaming
- **Messages**: Text announcements - **Messages**: Text announcements
- **WebUntis**: Educational schedule integration - **WebUntis**: Educational schedule integration
- **Recurrence & Holidays**: Recurring events can be configured to skip holidays. The backend generates EXDATEs (RecurrenceException) for holiday occurrences so the calendar never shows those instances. The "Termine an Ferientagen erlauben" toggle does not affect these events. - **Recurrence & Holidays**: Recurring events can be configured to skip holidays. The backend generates EXDATEs (RecurrenceException) for holiday occurrences using RFC 5545 timestamps (yyyyMMddTHHmmssZ), so the calendar never shows those instances. The scheduler expands recurring events for the next 7 days, applies event exceptions, and only deactivates recurring events after their recurrence_end (UNTIL). The "Termine an Ferientagen erlauben" toggle does not affect these events.
- **Single Occurrence Editing**: Users can edit individual occurrences of recurring events without affecting the master series. The system provides a confirmation dialog to choose between editing a single occurrence or the entire series. - **Single Occurrence Editing**: Users can edit individual occurrences of recurring events without affecting the master series. The system provides a confirmation dialog to choose between editing a single occurrence or the entire series.
### 🏫 **Academic Period Management** ### 🏫 **Academic Period Management**
@@ -139,25 +152,31 @@ For detailed deployment instructions, see:
- **Styling**: Centralized Syncfusion Material 3 CSS imports in `dashboard/src/main.tsx` - **Styling**: Centralized Syncfusion Material 3 CSS imports in `dashboard/src/main.tsx`
- **Features**: Responsive design, real-time updates, file management - **Features**: Responsive design, real-time updates, file management
- **Port**: 5173 (dev), served via Nginx (prod) - **Port**: 5173 (dev), served via Nginx (prod)
- **Data access**: No direct database connection; communicates with the API Server only via HTTP.
- **Dev proxy tip**: In development, use relative paths like `/api/...` in the frontend to route through Vite's proxy to the API. Avoid absolute URLs with an extra `/api` segment to prevent CORS or double-path issues.
### 🔧 **API Server** (`server/`) ### 🔧 **API Server** (`server/`)
- **Technology**: Flask + SQLAlchemy + Alembic - **Technology**: Flask + SQLAlchemy + Alembic
- **Database**: MariaDB with timezone-aware timestamps - **Database**: MariaDB with timezone-aware timestamps
- **Features**: RESTful API, file uploads, MQTT integration - **Features**: RESTful API, file uploads, MQTT integration
- Recurrence/holidays: returns only master events with `RecurrenceRule` and `RecurrenceException` (EXDATEs) so clients render recurrences and skip holiday instances reliably. - Recurrence/holidays: returns only master events with `RecurrenceRule` and `RecurrenceException` (EXDATEs) so clients render recurrences and skip holiday instances reliably.
- Recurring events are only deactivated after their recurrence_end (UNTIL); non-recurring events deactivate after their end time. Event exceptions are respected and rendered in scheduler output.
- Single occurrence detach: `POST /api/events/<id>/occurrences/<date>/detach` creates standalone events from recurring series without modifying the master event. - Single occurrence detach: `POST /api/events/<id>/occurrences/<date>/detach` creates standalone events from recurring series without modifying the master event.
- **Port**: 8000 - **Port**: 8000
- **Health Check**: `/health` - **Health Check**: `/health`
### 👂 **Listener** (`listener/`) ### 👂 **Listener** (`listener/`)
- **Technology**: Python + paho-mqtt
- **Purpose**: MQTT message processing, client discovery
- **Features**: Heartbeat monitoring, automatic client registration
### ⏰ **Scheduler** (`scheduler/`) ### ⏰ **Scheduler** (`scheduler/`)
- **Technology**: Python + SQLAlchemy **Technology**: Python + SQLAlchemy
- **Purpose**: Event publishing, group-based content distribution **Purpose**: Event publishing, group-based content distribution
- **Features**: Time-based event activation, MQTT publishing **Features**:
- Queries a future window (default: 7 days) to expand and publish recurring events
- Expands recurrences using RFC 5545 rules
- Applies event exceptions (skipped dates, detached occurrences)
- Only deactivates recurring events after their recurrence_end (UNTIL)
- Publishes all valid occurrences to MQTT
- Logging is concise; conversion lookups are cached and logged only once per media
### 🔄 **Worker** (Conversion Service) ### 🔄 **Worker** (Conversion Service)
- **Technology**: RQ (Redis Queue) + Gotenberg - **Technology**: RQ (Redis Queue) + Gotenberg
@@ -271,6 +290,14 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- `POST /api/conversions/{media_id}/pdf` - Request conversion - `POST /api/conversions/{media_id}/pdf` - Request conversion
- `GET /api/conversions/{media_id}/status` - Check conversion status - `GET /api/conversions/{media_id}/status` - Check conversion status
### System Settings
- `GET /api/system-settings` - List all system settings (admin+)
- `GET /api/system-settings/{key}` - Get a specific setting (admin+)
- `POST /api/system-settings/{key}` - Create or update a setting (admin+)
- `DELETE /api/system-settings/{key}` - Delete a setting (admin+)
- `GET /api/system-settings/supplement-table` - Get WebUntis/Vertretungsplan settings (enabled, url)
- `POST /api/system-settings/supplement-table` - Update WebUntis/Vertretungsplan settings
### Health & Monitoring ### Health & Monitoring
- `GET /health` - Service health check - `GET /health` - Service health check
- `GET /api/screenshots/{uuid}.jpg` - Client screenshots - `GET /api/screenshots/{uuid}.jpg` - Client screenshots
@@ -278,10 +305,11 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
## 🎨 Frontend Features ## 🎨 Frontend Features
### Recurrence & holidays ### Recurrence & holidays
- The frontend manually expands recurring events due to Syncfusion EXDATE handling limitations. - Recurrence is handled natively by Syncfusion. The API returns master events with `RecurrenceRule` and `RecurrenceException` (EXDATE) in RFC 5545 format (yyyyMMddTHHmmssZ, UTC) so the Scheduler excludes holiday instances reliably.
- The API supplies `RecurrenceException` (EXDATE) with exact occurrence start times (UTC) so holiday instances are excluded. - Events with "skip holidays" display a TentTree icon next to the main event icon (icon color: black). The Schedulers native lower-right recurrence badge indicates series membership.
- Events with "skip holidays" display a TentTree icon next to the main event icon. - Single occurrence editing: Users can edit either a single occurrence or the entire series. The UI persists changes using `onActionCompleted (requestType='eventChanged')`:
- Single occurrence editing: Users can detach individual occurrences via confirmation dialog, creating standalone events while preserving the master series. - Single occurrence → `POST /api/events/<id>/occurrences/<date>/detach` (creates standalone event and adds EXDATE to master)
- Series/single event → `PUT /api/events/<id>`
### Syncfusion Components Used (Material 3) ### Syncfusion Components Used (Material 3)
- **Schedule**: Event calendar with drag-drop support - **Schedule**: Event calendar with drag-drop support
@@ -292,6 +320,7 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- **Notifications**: Toast messages and alerts - **Notifications**: Toast messages and alerts
- **Pager**: Used on Programinfo changelog for pagination - **Pager**: Used on Programinfo changelog for pagination
- **Cards (layouts)**: Programinfo sections styled with Syncfusion card classes - **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 ### Pages Overview
- **Dashboard**: System overview and statistics - **Dashboard**: System overview and statistics
@@ -299,7 +328,12 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- **Groups**: Client group organization - **Groups**: Client group organization
- **Events**: Schedule management - **Events**: Schedule management
- **Media**: File upload and conversion - **Media**: File upload and conversion
- **Settings**: System configuration - **Settings**: Central configuration (tabbed)
- 📅 Academic Calendar (all users): School Holidays import/list and Academic Periods (set active period)
- 🖥️ Display & Clients (admin+): Defaults placeholders and quick links to Clients/Groups
- 🎬 Media & Files (admin+): Upload settings placeholders and Conversion status overview
- 🗓️ Events (admin+): WebUntis/Vertretungsplan URL enable/disable, save, preview; placeholders for other event types
- ⚙️ System (superadmin): Organization info and Advanced configuration placeholders
- **Holidays**: Academic calendar management - **Holidays**: Academic calendar management
- **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`) - **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`)
@@ -315,8 +349,7 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
## 📊 Monitoring & Logging ## 📊 Monitoring & Logging
### Health Checks ### Health Checks
All services include Docker health checks: **Scheduler**: Logging is concise; conversion lookups are cached and logged only once per media.
- API: HTTP endpoint monitoring
- Database: Connection and initialization status - Database: Connection and initialization status
- MQTT: Pub/sub functionality test - MQTT: Pub/sub functionality test
- Dashboard: Nginx availability - Dashboard: Nginx availability
@@ -395,6 +428,21 @@ docker exec -it infoscreen-db mysqladmin ping
``` ```
**MQTT communication issues** **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 ```bash
# Test MQTT broker # Test MQTT broker
mosquitto_pub -h localhost -t test -m "hello" 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-notifications": "^30.2.0",
"@syncfusion/ej2-react-popups": "^30.2.0", "@syncfusion/ej2-react-popups": "^30.2.0",
"@syncfusion/ej2-react-schedule": "^30.2.0", "@syncfusion/ej2-react-schedule": "^30.2.0",
"@syncfusion/ej2-react-splitbuttons": "^30.2.0",
"@syncfusion/ej2-splitbuttons": "^30.2.0", "@syncfusion/ej2-splitbuttons": "^30.2.0",
"cldr-data": "^36.0.4", "cldr-data": "^36.0.4",
"lucide-react": "^0.522.0", "lucide-react": "^0.522.0",
@@ -43,9 +44,6 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
@@ -64,26 +62,11 @@
"prettier": "^3.5.3", "prettier": "^3.5.3",
"stylelint": "^16.21.0", "stylelint": "^16.21.0",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",
"stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5" "vite": "^6.3.5"
} }
}, },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -1958,6 +1941,17 @@
"@syncfusion/ej2-schedule": "30.2.7" "@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": { "node_modules/@syncfusion/ej2-schedule": {
"version": "30.2.7", "version": "30.2.7",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-schedule/-/ej2-schedule-30.2.7.tgz", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-schedule/-/ej2-schedule-30.2.7.tgz",
@@ -1986,45 +1980,6 @@
"@syncfusion/ej2-popups": "~30.2.4" "@syncfusion/ej2-popups": "~30.2.4"
} }
}, },
"node_modules/@tailwindcss/aspect-ratio": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz",
"integrity": "sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
}
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mini-svg-data-uri": "^1.2.3"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
"integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2463,34 +2418,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2743,19 +2670,6 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -2900,16 +2814,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001741", "version": "1.0.30001741",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
@@ -2948,44 +2852,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/cldr-data": { "node_modules/cldr-data": {
"version": "36.0.4", "version": "36.0.4",
"resolved": "https://registry.npmjs.org/cldr-data/-/cldr-data-36.0.4.tgz", "resolved": "https://registry.npmjs.org/cldr-data/-/cldr-data-36.0.4.tgz",
@@ -3054,16 +2920,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3296,13 +3152,6 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dir-glob": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -3316,13 +3165,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4743,19 +4585,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-boolean-object": { "node_modules/is-boolean-object": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
@@ -5167,6 +4996,8 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -5302,19 +5133,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -5338,20 +5156,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5483,16 +5287,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
"dev": true,
"license": "MIT",
"bin": {
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -5536,18 +5330,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
"thenify-all": "^1.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -5623,16 +5405,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5915,26 +5687,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5974,141 +5726,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-import": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
"resolve": "^1.1.7"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss-import/node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/postcss-js": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
},
"engines": {
"node": "^12 || ^14 || >= 16"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
"peerDependencies": {
"postcss": "^8.4.21"
}
},
"node_modules/postcss-load-config": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.0.0",
"yaml": "^2.3.4"
},
"engines": {
"node": ">= 14"
},
"peerDependencies": {
"postcss": ">=8.0.9",
"ts-node": ">=9.0.0"
},
"peerDependenciesMeta": {
"postcss": {
"optional": true
},
"ts-node": {
"optional": true
}
}
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "^6.1.1"
},
"engines": {
"node": ">=12.0"
},
"peerDependencies": {
"postcss": "^8.2.14"
}
},
"node_modules/postcss-nested/node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/postcss-resolve-nested-selector": { "node_modules/postcss-resolve-nested-selector": {
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
@@ -6143,20 +5760,6 @@
"postcss": "^8.4.31" "postcss": "^8.4.31"
} }
}, },
"node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/postcss-value-parser": { "node_modules/postcss-value-parser": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@@ -6339,29 +5942,6 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7129,17 +6709,6 @@
"stylelint": "^16.18.0" "stylelint": "^16.18.0"
} }
}, },
"node_modules/stylelint-config-tailwindcss": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/stylelint-config-tailwindcss/-/stylelint-config-tailwindcss-1.0.0.tgz",
"integrity": "sha512-e6WUBJeLdOZ0sy8FZ1jk5Zy9iNGqqJbrMwnnV0Hpaw/yin6QO3gVv/zvyqSty8Yg6nEB5gqcyJbN387TPhEa7Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"stylelint": ">=13.13.1",
"tailwindcss": ">=2.2.16"
}
},
"node_modules/stylelint/node_modules/@csstools/selector-specificity": { "node_modules/stylelint/node_modules/@csstools/selector-specificity": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz",
@@ -7261,29 +6830,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"glob": "^10.3.10",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"ts-interface-checker": "^0.1.9"
},
"bin": {
"sucrase": "bin/sucrase",
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -7435,102 +6981,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
"chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.6",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
"picocolors": "^1.1.1",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
"sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
"tailwindcss": "lib/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tailwindcss/node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
},
"node_modules/thenify-all": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -7605,13 +7055,6 @@
"typescript": ">=4.8.4" "typescript": ">=4.8.4"
} }
}, },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -8143,6 +7586,8 @@
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View File

@@ -36,6 +36,7 @@
"@syncfusion/ej2-react-notifications": "^30.2.0", "@syncfusion/ej2-react-notifications": "^30.2.0",
"@syncfusion/ej2-react-popups": "^30.2.0", "@syncfusion/ej2-react-popups": "^30.2.0",
"@syncfusion/ej2-react-schedule": "^30.2.0", "@syncfusion/ej2-react-schedule": "^30.2.0",
"@syncfusion/ej2-react-splitbuttons": "^30.2.0",
"@syncfusion/ej2-splitbuttons": "^30.2.0", "@syncfusion/ej2-splitbuttons": "^30.2.0",
"cldr-data": "^36.0.4", "cldr-data": "^36.0.4",
"lucide-react": "^0.522.0", "lucide-react": "^0.522.0",

View File

@@ -1,6 +1,6 @@
{ {
"appName": "Infoscreen-Management", "appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.8", "version": "2025.1.0-alpha.11",
"copyright": "© 2025 Third-Age-Applications", "copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com", "supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -30,6 +30,37 @@
"commitId": "8d1df7199cb7" "commitId": "8d1df7199cb7"
}, },
"changelog": [ "changelog": [
{
"version": "2025.1.0-alpha.11",
"date": "2025-10-16",
"changes": [
"✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit Tabs: 📅 Akademischer Kalender, 🖥️ Anzeige & Clients, 🎬 Medien & Dateien, 🗓️ Events, ⚙️ System.",
"🗓️ Einstellungen Events: WebUntis/Vertretungsplan Zusatz-Tabelle (URL) in den Events-Tab verschoben; Aktivieren/Deaktivieren, Speichern und Vorschau; systemweite Einstellung.",
"📅 Einstellungen Akademischer Kalender: Aktive akademische Periode kann direkt gesetzt werden.",
"🛠️ Einstellungen (Technik): API-Aufrufe nutzen nun relative /apiPfade über den ViteProxy (verhindert CORS bzw. doppeltes /api).",
"📖 Doku: README zur Einstellungen-Seite (Tabs) und System-Settings-API ergänzt."
]
},
{
"version": "2025.1.0-alpha.10",
"date": "2025-10-15",
"changes": [
"🔐 Auth: Login und Benutzerverwaltung implementiert (rollenbasiert, persistente Sitzungen).",
"✨ 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",
"changes": [
"✨ UI: Einheitlicher Lösch-Workflow für Termine alle Typen (Einzeltermin, Einzelinstanz, ganze Serie) werden mit eigenen, benutzerfreundlichen Dialogen behandelt.",
"🔧 Frontend: Syncfusion-RecurrenceAlert und DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (inkl. finale Bestätigung für Serienlöschung).",
"✅ Bugfix: Keine doppelten oder verwirrenden Bestätigungsdialoge mehr beim Löschen von Serienterminen.",
"📖 Doku: README und Copilot-Instructions um Lösch-Workflow und Dialoghandling erweitert."
]
},
{ {
"version": "2025.1.0-alpha.8", "version": "2025.1.0-alpha.8",
"date": "2025-10-11", "date": "2025-10-11",

View File

@@ -1,7 +1,9 @@
import React, { useState } from 'react'; 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 { SidebarComponent } from '@syncfusion/ej2-react-navigations';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; 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 { TooltipComponent } from '@syncfusion/ej2-react-popups';
import logo from './assets/logo.png'; import logo from './assets/logo.png';
import './App.css'; import './App.css';
@@ -42,11 +44,13 @@ import Ressourcen from './ressourcen';
import Infoscreens from './clients'; import Infoscreens from './clients';
import Infoscreen_groups from './infoscreen_groups'; import Infoscreen_groups from './infoscreen_groups';
import Media from './media'; import Media from './media';
import Benutzer from './benutzer'; import Benutzer from './users';
import Einstellungen from './einstellungen'; import Einstellungen from './settings';
import SetupMode from './SetupMode'; import SetupMode from './SetupMode';
import Programminfo from './programminfo'; import Programminfo from './programminfo';
import Logout from './logout'; 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) // ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
// const ENV = import.meta.env.VITE_ENV || 'development'; // const ENV = import.meta.env.VITE_ENV || 'development';
@@ -55,6 +59,8 @@ const Layout: React.FC = () => {
const [version, setVersion] = useState(''); const [version, setVersion] = useState('');
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
let sidebarRef: SidebarComponent | null; let sidebarRef: SidebarComponent | null;
const { user } = useAuth();
const navigate = useNavigate();
React.useEffect(() => { React.useEffect(() => {
fetch('/program-info.json') fetch('/program-info.json')
@@ -292,9 +298,46 @@ const Layout: React.FC = () => {
<span className="text-2xl font-bold mr-8" style={{ color: '#78591c' }}> <span className="text-2xl font-bold mr-8" style={{ color: '#78591c' }}>
Infoscreen-Management Infoscreen-Management
</span> </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] [Organisationsname]
</span> </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> </header>
<main className="page-content"> <main className="page-content">
<Outlet /> <Outlet />
@@ -307,10 +350,24 @@ const Layout: React.FC = () => {
const App: React.FC = () => { const App: React.FC = () => {
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt // 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 ( return (
<ToastProvider> <ToastProvider>
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} /> <Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} /> <Route path="ressourcen" element={<Ressourcen />} />
@@ -323,6 +380,7 @@ const App: React.FC = () => {
<Route path="programminfo" element={<Programminfo />} /> <Route path="programminfo" element={<Programminfo />} />
</Route> </Route>
<Route path="/logout" element={<Logout />} /> <Route path="/logout" element={<Logout />} />
<Route path="/login" element={<Login />} />
</Routes> </Routes>
</ToastProvider> </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']);
}

View File

@@ -32,8 +32,12 @@ export async function fetchEventById(eventId: string) {
return data; return data;
} }
export async function deleteEvent(eventId: string) { export async function deleteEvent(eventId: string, force: boolean = false) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, { const url = force
? `/api/events/${encodeURIComponent(eventId)}?force=1`
: `/api/events/${encodeURIComponent(eventId)}`;
const res = await fetch(url, {
method: 'DELETE', method: 'DELETE',
}); });
const data = await res.json(); const data = await res.json();

View File

@@ -0,0 +1,108 @@
/**
* API client for system settings
*/
export interface SystemSetting {
key: string;
value: string | null;
description: string | null;
updated_at: string | null;
}
export interface SupplementTableSettings {
url: string;
enabled: boolean;
}
/**
* Get all system settings
*/
export async function getAllSettings(): Promise<{ settings: SystemSetting[] }> {
const response = await fetch(`/api/system-settings`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch settings: ${response.statusText}`);
}
return response.json();
}
/**
* Get a specific setting by key
*/
export async function getSetting(key: string): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch setting: ${response.statusText}`);
}
return response.json();
}
/**
* Update or create a setting
*/
export async function updateSetting(
key: string,
value: string,
description?: string
): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ value, description }),
});
if (!response.ok) {
throw new Error(`Failed to update setting: ${response.statusText}`);
}
return response.json();
}
/**
* Delete a setting
*/
export async function deleteSetting(key: string): Promise<{ message: string }> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to delete setting: ${response.statusText}`);
}
return response.json();
}
/**
* Get supplement table settings
*/
export async function getSupplementTableSettings(): Promise<SupplementTableSettings> {
const response = await fetch(`/api/system-settings/supplement-table`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch supplement table settings: ${response.statusText}`);
}
return response.json();
}
/**
* Update supplement table settings
*/
export async function updateSupplementTableSettings(
url: string,
enabled: boolean
): Promise<SupplementTableSettings & { message: string }> {
const response = await fetch(`/api/system-settings/supplement-table`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url, enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update supplement table settings: ${response.statusText}`);
}
return response.json();
}

View File

@@ -75,22 +75,6 @@ type Event = {
RecurrenceException?: string; RecurrenceException?: string;
}; };
type RawEvent = {
Id: string;
Subject: string;
StartTime: string;
EndTime: string;
IsAllDay: boolean;
MediaId?: string | number;
Icon?: string; // <--- Icon ergänzen!
Type?: string;
OccurrenceOfId?: string;
RecurrenceRule?: string | null;
RecurrenceEnd?: string | null;
SkipHolidays?: boolean;
RecurrenceException?: string;
};
// CLDR-Daten laden (direkt die JSON-Objekte übergeben) // CLDR-Daten laden (direkt die JSON-Objekte übergeben)
loadCldr( loadCldr(
caGregorian as object, caGregorian as object,
@@ -208,6 +192,7 @@ const Appointments: React.FC = () => {
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]); const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null); const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
// Confirmation dialog state // Confirmation dialog state
const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false);
const [confirmDialogData, setConfirmDialogData] = React.useState<{ const [confirmDialogData, setConfirmDialogData] = React.useState<{
@@ -217,6 +202,44 @@ const Appointments: React.FC = () => {
onCancel: () => void; onCancel: () => void;
} | null>(null); } | null>(null);
// Recurring deletion dialog state
const [recurringDeleteDialogOpen, setRecurringDeleteDialogOpen] = React.useState(false);
const [recurringDeleteData, setRecurringDeleteData] = React.useState<{
event: Event;
onChoice: (choice: 'series' | 'occurrence' | 'cancel') => void;
} | null>(null);
// Series deletion final confirmation dialog (after choosing 'series')
const [seriesConfirmDialogOpen, setSeriesConfirmDialogOpen] = React.useState(false);
const [seriesConfirmData, setSeriesConfirmData] = React.useState<{
event: Event;
onConfirm: () => void;
onCancel: () => void;
} | null>(null);
const showSeriesConfirmDialog = (event: Event): Promise<boolean> => {
return new Promise(resolve => {
console.log('[Delete] showSeriesConfirmDialog invoked for event', event.Id);
// Defer open to next tick to avoid race with closing previous dialog
setSeriesConfirmData({
event,
onConfirm: () => {
console.log('[Delete] Series confirm dialog: confirmed');
setSeriesConfirmDialogOpen(false);
resolve(true);
},
onCancel: () => {
console.log('[Delete] Series confirm dialog: cancelled');
setSeriesConfirmDialogOpen(false);
resolve(false);
}
});
setTimeout(() => {
setSeriesConfirmDialogOpen(true);
}, 0);
});
};
// Helper function to show confirmation dialog // Helper function to show confirmation dialog
const showConfirmDialog = (title: string, message: string): Promise<boolean> => { const showConfirmDialog = (title: string, message: string): Promise<boolean> => {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -236,6 +259,20 @@ const Appointments: React.FC = () => {
}); });
}; };
// Helper function to show recurring event deletion dialog
const showRecurringDeleteDialog = (event: Event): Promise<'series' | 'occurrence' | 'cancel'> => {
return new Promise((resolve) => {
setRecurringDeleteData({
event,
onChoice: (choice: 'series' | 'occurrence' | 'cancel') => {
setRecurringDeleteDialogOpen(false);
resolve(choice);
}
});
setRecurringDeleteDialogOpen(true);
});
};
// Gruppen laden // Gruppen laden
useEffect(() => { useEffect(() => {
fetchGroups() fetchGroups()
@@ -563,6 +600,22 @@ const Appointments: React.FC = () => {
updateHolidaysInView(); updateHolidaysInView();
}, [holidays, updateHolidaysInView]); }, [holidays, updateHolidaysInView]);
// Inject global z-index fixes for dialogs (only once)
React.useEffect(() => {
if (typeof document !== 'undefined' && !document.getElementById('series-dialog-zfix')) {
const style = document.createElement('style');
style.id = 'series-dialog-zfix';
style.textContent = `\n .final-series-dialog.e-dialog { z-index: 25000 !important; }\n .final-series-dialog + .e-dlg-overlay { z-index: 24990 !important; }\n .recurring-delete-dialog.e-dialog { z-index: 24000 !important; }\n .recurring-delete-dialog + .e-dlg-overlay { z-index: 23990 !important; }\n `;
document.head.appendChild(style);
}
}, []);
React.useEffect(() => {
if (seriesConfirmDialogOpen) {
console.log('[Delete] Series confirm dialog now visible');
}
}, [seriesConfirmDialogOpen]);
return ( return (
<div> <div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 16 }}>Terminmanagement</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 16 }}>Terminmanagement</h1>
@@ -733,17 +786,25 @@ const Appointments: React.FC = () => {
setModalOpen(false); setModalOpen(false);
setEditMode(false); // Editiermodus zurücksetzen setEditMode(false); // Editiermodus zurücksetzen
}} }}
onSave={async () => { onSave={async (eventData) => {
console.log('Modal saved event data:', eventData);
// The CustomEventModal already handled the API calls internally
// For now, just refresh the data (the recurring event logic is handled in the modal itself)
console.log('Modal operation completed, refreshing data');
setModalOpen(false); setModalOpen(false);
setEditMode(false); setEditMode(false);
// Force immediate data refresh // Refresh the data and scheduler
await fetchAndSetEvents(); await fetchAndSetEvents();
// Defer refresh to avoid interfering with current React commit // Defer refresh to avoid interfering with current React commit
setTimeout(() => { setTimeout(() => {
scheduleRef.current?.refreshEvents?.(); scheduleRef.current?.refreshEvents?.();
}, 0); }, 0);
console.log('Modal save cycle completed - data refreshed');
}} }}
initialData={modalInitialData} initialData={modalInitialData}
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }} groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
@@ -781,6 +842,7 @@ const Appointments: React.FC = () => {
// Persist UI-driven changes (drag/resize/editor fallbacks) // Persist UI-driven changes (drag/resize/editor fallbacks)
if (args && args.requestType === 'eventChanged') { if (args && args.requestType === 'eventChanged') {
console.log('actionComplete: Processing eventChanged from direct UI interaction (drag/resize)');
try { try {
type SchedulerEvent = Partial<Event> & { type SchedulerEvent = Partial<Event> & {
Id?: string | number; Id?: string | number;
@@ -810,18 +872,64 @@ const Appointments: React.FC = () => {
payload.end = e.toISOString(); payload.end = e.toISOString();
} }
// Single occurrence change from a recurring master (our manual expansion marks OccurrenceOfId) // Check if this is a single occurrence edit by looking at the original master event
if (changed.OccurrenceOfId) { const eventId = String(changed.Id);
if (!changed.StartTime) return; // cannot determine occurrence date
// Debug logging to understand what Syncfusion sends
console.log('actionComplete eventChanged - Debug info:', {
eventId,
changedRecurrenceRule: changed.RecurrenceRule,
changedRecurrenceID: changed.RecurrenceID,
changedStartTime: changed.StartTime,
changedSubject: changed.Subject,
payload,
fullChangedObject: JSON.stringify(changed, null, 2)
});
// First, fetch the master event to check if it has a RecurrenceRule
let masterEvent = null;
let isMasterRecurring = false;
try {
masterEvent = await fetchEventById(eventId);
isMasterRecurring = !!masterEvent.RecurrenceRule;
console.log('Master event info:', {
masterRecurrenceRule: masterEvent.RecurrenceRule,
masterStartTime: masterEvent.StartTime,
isMasterRecurring
});
} catch (err) {
console.error('Failed to fetch master event:', err);
}
// KEY DETECTION: Syncfusion sets RecurrenceID when editing a single occurrence
const hasRecurrenceID = 'RecurrenceID' in changed && !!(changed as Record<string, unknown>).RecurrenceID;
// When dragging a single occurrence, Syncfusion may not provide RecurrenceID
// but it won't provide RecurrenceRule on the changed object
const isRecurrenceRuleStripped = isMasterRecurring && !changed.RecurrenceRule;
console.log('FINAL Edit detection:', {
isMasterRecurring,
hasRecurrenceID,
isRecurrenceRuleStripped,
masterHasRule: masterEvent?.RecurrenceRule ? 'YES' : 'NO',
changedHasRule: changed.RecurrenceRule ? 'YES' : 'NO',
decision: (hasRecurrenceID || isRecurrenceRuleStripped) ? 'DETACH' : 'UPDATE'
});
// SINGLE OCCURRENCE EDIT detection:
// 1. RecurrenceID is set (explicit single occurrence marker)
// 2. OR master has RecurrenceRule but changed object doesn't (stripped during single edit)
if (isMasterRecurring && (hasRecurrenceID || isRecurrenceRuleStripped) && changed.StartTime) {
// This is a single occurrence edit - detach it
console.log('Detaching single occurrence...');
const occStart = changed.StartTime instanceof Date ? changed.StartTime : new Date(changed.StartTime as string); const occStart = changed.StartTime instanceof Date ? changed.StartTime : new Date(changed.StartTime as string);
const occDate = occStart.toISOString().split('T')[0]; const occDate = occStart.toISOString().split('T')[0];
await detachEventOccurrence(Number(changed.OccurrenceOfId), occDate, payload); await detachEventOccurrence(Number(eventId), occDate, payload);
} else if (changed.RecurrenceRule) { } else {
// Change to master series (non-manually expanded recurrences) // This is a series edit or regular single event
await updateEvent(String(changed.Id), payload); console.log('Updating event directly...');
} else if (changed.Id) { await updateEvent(eventId, payload);
// Regular single event
await updateEvent(String(changed.Id), payload);
} }
// Refresh events and scheduler cache after persisting // Refresh events and scheduler cache after persisting
@@ -860,6 +968,96 @@ const Appointments: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}} }}
popupOpen={async args => { popupOpen={async args => {
// Intercept Syncfusion's recurrence choice dialog (RecurrenceAlert) and replace with custom
if (args.type === 'RecurrenceAlert') {
// Prevent default Syncfusion dialog
args.cancel = true;
const event = args.data;
console.log('[RecurrenceAlert] Intercepted for event', event?.Id);
if (!event) return;
// Show our custom recurring delete dialog
const choice = await showRecurringDeleteDialog(event);
let didDelete = false;
try {
if (choice === 'series') {
const confirmed = await showSeriesConfirmDialog(event);
if (confirmed) {
await deleteEvent(event.Id, true);
didDelete = true;
}
} else if (choice === 'occurrence') {
const occurrenceDate = event.StartTime instanceof Date
? event.StartTime.toISOString().split('T')[0]
: new Date(event.StartTime).toISOString().split('T')[0];
// If this is the master being edited for a single occurrence, treat as occurrence delete
if (event.OccurrenceOfId) {
await deleteEventOccurrence(event.OccurrenceOfId, occurrenceDate);
} else {
await deleteEventOccurrence(event.Id, occurrenceDate);
}
didDelete = true;
}
} catch (e) {
console.error('Fehler bei RecurrenceAlert Löschung:', e);
}
if (didDelete) {
await fetchAndSetEvents();
setTimeout(() => scheduleRef.current?.refreshEvents?.(), 0);
}
return; // handled
}
if (args.type === 'DeleteAlert') {
// Handle delete confirmation directly here to avoid multiple dialogs
args.cancel = true;
const event = args.data;
let didDelete = false;
try {
// 1) Single occurrence of a recurring event → delete occurrence only
if (event.OccurrenceOfId && event.StartTime) {
console.log('[Delete] Deleting single occurrence via OccurrenceOfId path', {
eventId: event.Id,
masterId: event.OccurrenceOfId,
start: event.StartTime
});
const occurrenceDate = event.StartTime instanceof Date
? event.StartTime.toISOString().split('T')[0]
: new Date(event.StartTime).toISOString().split('T')[0];
await deleteEventOccurrence(event.OccurrenceOfId, occurrenceDate);
didDelete = true;
}
// 2) Recurring master event deletion → show deletion choice dialog
else if (event.RecurrenceRule) {
// For recurring events the RecurrenceAlert should have been intercepted.
console.log('[DeleteAlert] Recurring event delete without RecurrenceAlert (fallback)');
const confirmed = await showSeriesConfirmDialog(event);
if (confirmed) {
await deleteEvent(event.Id, true);
didDelete = true;
}
}
// 3) Single non-recurring event → delete normally with simple confirmation
else {
console.log('Deleting single non-recurring event:', event.Id);
await deleteEvent(event.Id, false);
didDelete = true;
}
// Refresh events only if a deletion actually occurred
if (didDelete) {
await fetchAndSetEvents();
setTimeout(() => {
scheduleRef.current?.refreshEvents?.();
}, 0);
}
} catch (err) {
console.error('Fehler beim Löschen:', err);
}
return; // Exit early for delete operations
}
if (args.type === 'Editor') { if (args.type === 'Editor') {
args.cancel = true; args.cancel = true;
const event = args.data; const event = args.data;
@@ -943,9 +1141,11 @@ const Appointments: React.FC = () => {
} }
} }
// Fixed: Ensure OccurrenceOfId is set for recurring events in native recurrence mode
const modalData = { const modalData = {
Id: (event.OccurrenceOfId && !isSingleOccurrence) ? event.OccurrenceOfId : event.Id, // Use master ID for series edit, occurrence ID for single edit Id: (event.OccurrenceOfId && !isSingleOccurrence) ? event.OccurrenceOfId : event.Id, // Use master ID for series edit, occurrence ID for single edit
OccurrenceOfId: event.OccurrenceOfId, // Master event ID if this is an occurrence OccurrenceOfId: event.OccurrenceOfId || (event.RecurrenceRule ? event.Id : undefined), // Master event ID - use current ID if it's a recurring master
occurrenceDate: isSingleOccurrence ? event.StartTime : null, // Store occurrence date for single occurrence editing occurrenceDate: isSingleOccurrence ? event.StartTime : null, // Store occurrence date for single occurrence editing
isSingleOccurrence, isSingleOccurrence,
title: eventDataToUse.Subject, title: eventDataToUse.Subject,
@@ -1029,54 +1229,14 @@ const Appointments: React.FC = () => {
} }
}} }}
actionBegin={async (args: ActionEventArgs) => { actionBegin={async (args: ActionEventArgs) => {
// Delete operations are now handled in popupOpen to avoid multiple dialogs
if (args.requestType === 'eventRemove') { if (args.requestType === 'eventRemove') {
// args.data ist ein Array von zu löschenden Events // Cancel all delete operations here - they're handled in popupOpen
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
for (const ev of toDelete) {
try {
// 1) Single occurrence of a recurring event → delete occurrence only
if (ev.OccurrenceOfId && ev.StartTime) {
const occurrenceDate = ev.StartTime instanceof Date
? ev.StartTime.toISOString().split('T')[0]
: new Date(ev.StartTime).toISOString().split('T')[0];
await deleteEventOccurrence(ev.OccurrenceOfId, occurrenceDate);
continue;
}
// 2) Recurring master being removed unexpectedly → block deletion (safety)
// Syncfusion can sometimes raise eventRemove during edits; do NOT delete the series here.
if (ev.RecurrenceRule) {
console.warn('Blocked deletion of recurring master event via eventRemove.');
// If the user truly wants to delete the series, provide an explicit UI path.
continue;
}
// 3) Single non-recurring event → delete normally
await deleteEvent(ev.Id);
} catch (err) {
console.error('Fehler beim Löschen:', err);
}
}
// Events nach Löschen neu laden
if (selectedGroupId) {
fetchEvents(selectedGroupId, showInactive)
.then((data: RawEvent[]) => {
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SkipHolidays: e.SkipHolidays ?? false,
}));
setEvents(mapped);
})
.catch(console.error);
}
// Syncfusion soll das Event nicht selbst löschen
args.cancel = true; args.cancel = true;
} else if ( return;
}
if (
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') && (args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
!allowScheduleOnHolidays !allowScheduleOnHolidays
) { ) {
@@ -1156,6 +1316,167 @@ const Appointments: React.FC = () => {
</div> </div>
</DialogComponent> </DialogComponent>
)} )}
{/* Recurring Event Deletion Dialog */}
{recurringDeleteDialogOpen && recurringDeleteData && (
<DialogComponent
target="#root"
visible={recurringDeleteDialogOpen}
width="500px"
zIndex={18000}
cssClass="recurring-delete-dialog"
header={() => (
<div style={{
padding: '12px 20px',
background: '#dc3545',
color: 'white',
fontWeight: 600,
borderRadius: '6px 6px 0 0'
}}>
🗑 Wiederkehrenden Termin löschen
</div>
)}
showCloseIcon={true}
close={() => recurringDeleteData.onChoice('cancel')}
isModal={true}
footerTemplate={() => (
<div style={{ padding: '12px 20px', display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
className="e-btn e-outline"
onClick={() => recurringDeleteData.onChoice('cancel')}
style={{ minWidth: '100px' }}
>
Abbrechen
</button>
<button
className="e-btn e-warning"
onClick={() => recurringDeleteData.onChoice('occurrence')}
style={{ minWidth: '140px' }}
>
Nur diesen Termin
</button>
<button
className="e-btn e-danger"
onClick={() => recurringDeleteData.onChoice('series')}
style={{ minWidth: '140px' }}
>
Gesamte Serie
</button>
</div>
)}
>
<div style={{ padding: '24px', fontSize: '14px', lineHeight: 1.5 }}>
<div style={{ marginBottom: '16px', fontSize: '16px', fontWeight: 500 }}>
Sie möchten einen wiederkehrenden Termin löschen:
</div>
<div style={{
background: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '6px',
padding: '12px',
marginBottom: '20px',
fontWeight: 500
}}>
📅 {recurringDeleteData.event.Subject}
</div>
<div style={{ marginBottom: '16px' }}>
<strong>Was möchten Sie löschen?</strong>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<span style={{ color: '#fd7e14', fontSize: '16px' }}>📝</span>
<div>
<strong>Nur diesen Termin:</strong> Löscht nur den ausgewählten Termin. Die anderen Termine der Serie bleiben bestehen.
</div>
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<span style={{ color: '#dc3545', fontSize: '16px' }}></span>
<div>
<strong>Gesamte Serie:</strong> Löscht <u>alle Termine</u> dieser Wiederholungsserie. Diese Aktion kann nicht rückgängig gemacht werden!
</div>
</div>
</div>
</div>
</DialogComponent>
)}
{/* Final Series Deletion Confirmation Dialog */}
{seriesConfirmDialogOpen && seriesConfirmData && (
<DialogComponent
target="#root"
visible={seriesConfirmDialogOpen}
width="520px"
zIndex={19000}
cssClass="final-series-dialog"
header={() => (
<div style={{
padding: '12px 20px',
background: '#b91c1c',
color: 'white',
fontWeight: 600,
borderRadius: '6px 6px 0 0'
}}>
Serie endgültig löschen
</div>
)}
showCloseIcon={true}
close={() => seriesConfirmData.onCancel()}
isModal={true}
footerTemplate={() => (
<div style={{ padding: '12px 20px', display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
className="e-btn e-outline"
onClick={seriesConfirmData.onCancel}
style={{ minWidth: '110px' }}
>
Abbrechen
</button>
<button
className="e-btn e-danger"
onClick={seriesConfirmData.onConfirm}
style={{ minWidth: '180px' }}
>
Serie löschen
</button>
</div>
)}
>
<div style={{ padding: '24px', fontSize: '14px', lineHeight: 1.55 }}>
<div style={{ marginBottom: '14px' }}>
Sie sind dabei die <strong>gesamte Terminserie</strong> zu löschen:
</div>
<div style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 6,
padding: '10px 14px',
marginBottom: 18,
fontWeight: 500
}}>
📅 {seriesConfirmData.event.Subject}
</div>
<ul style={{ margin: '0 0 18px 18px', padding: 0 }}>
<li>Alle zukünftigen und vergangenen Vorkommen werden entfernt.</li>
<li>Dieser Vorgang kann nicht rückgängig gemacht werden.</li>
<li>Einzelne bereits abgetrennte Einzeltermine bleiben bestehen.</li>
</ul>
<div style={{
background: '#fff7ed',
border: '1px solid #ffedd5',
borderRadius: 6,
padding: '10px 14px',
fontSize: 13
}}>
Wenn Sie nur einen einzelnen Termin entfernen möchten, schließen Sie diesen Dialog und wählen Sie im vorherigen Dialog "Nur diesen Termin".
</div>
</div>
</DialogComponent>
)}
</div> </div>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useAuth } from '../useAuth';
import { DialogComponent } from '@syncfusion/ej2-react-popups'; import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { import {
FileManagerComponent, FileManagerComponent,
@@ -19,6 +20,8 @@ type CustomSelectUploadEventModalProps = {
const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => { const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => {
const { open, onClose, onSelect } = props; const { open, onClose, onSelect } = props;
const { user } = useAuth();
const isSuperadmin = useMemo(() => user?.role === 'superadmin', [user]);
const [selectedFile, setSelectedFile] = useState<{ const [selectedFile, setSelectedFile] = useState<{
id: string; id: string;
@@ -63,6 +66,23 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
} }
}; };
type FileItem = { name: string; isFile: boolean };
type ReadSuccessArgs = { action: string; result?: { files?: FileItem[] } };
type FileOpenArgs = { fileDetails?: FileItem; cancel?: boolean };
const handleSuccess = (args: ReadSuccessArgs) => {
if (isSuperadmin) return;
if (args && args.action === 'read' && args.result && Array.isArray(args.result.files)) {
args.result.files = args.result.files.filter((f: FileItem) => !(f.name === 'converted' && !f.isFile));
}
};
const handleFileOpen = (args: FileOpenArgs) => {
if (!isSuperadmin && args && args.fileDetails && args.fileDetails.name === 'converted' && !args.fileDetails.isFile) {
args.cancel = true;
}
};
return ( return (
<DialogComponent <DialogComponent
target="#root" target="#root"
@@ -84,6 +104,9 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
)} )}
> >
<FileManagerComponent <FileManagerComponent
cssClass="e-bigger media-icons-xl"
success={handleSuccess}
fileOpen={handleFileOpen}
ajaxSettings={{ ajaxSettings={{
url: hostUrl + 'operations', url: hostUrl + 'operations',
getImageUrl: hostUrl + 'get-image', getImageUrl: hostUrl + 'get-image',

View File

@@ -1,87 +0,0 @@
import React from 'react';
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
const Einstellungen: React.FC = () => {
const [file, setFile] = React.useState<File | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const [holidays, setHolidays] = React.useState<Holiday[]>([]);
const refresh = React.useCallback(async () => {
try {
const data = await listHolidays();
setHolidays(data.holidays);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
setMessage(msg);
}
}, []);
React.useEffect(() => {
refresh();
}, [refresh]);
const onUpload = async () => {
if (!file) return;
setBusy(true);
setMessage(null);
try {
const res = await uploadHolidaysCsv(file);
setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`);
await refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
setMessage(msg);
} finally {
setBusy(false);
}
};
return (
<div>
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
<div className="space-y-4">
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
<p className="text-sm text-gray-600 mb-2">
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>,{' '}
<code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>,{' '}
<strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne
Info (ignoriert)
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv,.txt,text/plain"
onChange={e => setFile(e.target.files?.[0] ?? null)}
/>
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</button>
</div>
{message && <div className="mt-2 text-sm">{message}</div>}
</section>
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Importierte Ferien</h3>
{holidays.length === 0 ? (
<div className="text-sm text-gray-600">Keine Einträge vorhanden.</div>
) : (
<ul className="text-sm list-disc pl-6">
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
};
export default Einstellungen;

View File

@@ -1,5 +1,7 @@
/* Tailwind removed: base/components/utilities directives no longer used. */ /* Tailwind removed: base/components/utilities directives no longer used. */
/* Custom overrides moved to theme-overrides.css to load after Syncfusion styles */
/* :root { /* :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;

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

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
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 navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
try {
await login(username, password);
setMessage('Login erfolgreich');
// Redirect to dashboard after successful login
navigate('/');
} 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="flex items-center justify-center h-screen">
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-bold mb-4">Abmeldung</h2> <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>
</div> </div>
); );
};
export default Logout; export default Logout;

View File

@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App.tsx'; import App from './App.tsx';
import { AuthProvider } from './useAuth';
import { registerLicense } from '@syncfusion/ej2-base'; import { registerLicense } from '@syncfusion/ej2-base';
import '@syncfusion/ej2-base/styles/material3.css'; import '@syncfusion/ej2-base/styles/material3.css';
import '@syncfusion/ej2-navigations/styles/material3.css'; import '@syncfusion/ej2-navigations/styles/material3.css';
@@ -20,6 +21,7 @@ import '@syncfusion/ej2-lists/styles/material3.css';
import '@syncfusion/ej2-calendars/styles/material3.css'; import '@syncfusion/ej2-calendars/styles/material3.css';
import '@syncfusion/ej2-splitbuttons/styles/material3.css'; import '@syncfusion/ej2-splitbuttons/styles/material3.css';
import '@syncfusion/ej2-icons/styles/material3.css'; import '@syncfusion/ej2-icons/styles/material3.css';
import './theme-overrides.css';
// Setze hier deinen Lizenzschlüssel ein // Setze hier deinen Lizenzschlüssel ein
registerLicense( registerLicense(
@@ -28,6 +30,8 @@ registerLicense(
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</StrictMode> </StrictMode>
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel'; import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
import { import {
FileManagerComponent, FileManagerComponent,
@@ -7,10 +7,13 @@ import {
DetailsView, DetailsView,
Toolbar, Toolbar,
} from '@syncfusion/ej2-react-filemanager'; } from '@syncfusion/ej2-react-filemanager';
import { useAuth } from './useAuth';
const hostUrl = '/api/eventmedia/filemanager/'; // Dein Backend-Endpunkt für FileManager const hostUrl = '/api/eventmedia/filemanager/'; // Dein Backend-Endpunkt für FileManager
const Media: React.FC = () => { const Media: React.FC = () => {
const { user } = useAuth();
const isSuperadmin = useMemo(() => user?.role === 'superadmin', [user]);
// State für die angezeigten Dateidetails // State für die angezeigten Dateidetails
const [fileDetails] = useState<null | { const [fileDetails] = useState<null | {
name: string; name: string;
@@ -43,6 +46,25 @@ const Media: React.FC = () => {
} }
}, [viewMode]); }, [viewMode]);
type FileItem = { name: string; isFile: boolean };
type ReadSuccessArgs = { action: string; result?: { files?: FileItem[] } };
type FileOpenArgs = { fileDetails?: FileItem; cancel?: boolean };
// Hide "converted" for non-superadmins after data load
const handleSuccess = (args: ReadSuccessArgs) => {
if (isSuperadmin) return;
if (args && args.action === 'read' && args.result && Array.isArray(args.result.files)) {
args.result.files = args.result.files.filter((f: FileItem) => !(f.name === 'converted' && !f.isFile));
}
};
// Prevent opening the "converted" folder for non-superadmins
const handleFileOpen = (args: FileOpenArgs) => {
if (!isSuperadmin && args && args.fileDetails && args.fileDetails.name === 'converted' && !args.fileDetails.isFile) {
args.cancel = true;
}
};
return ( return (
<div> <div>
<h2 className="text-xl font-bold mb-4">Medien</h2> <h2 className="text-xl font-bold mb-4">Medien</h2>
@@ -65,6 +87,9 @@ const Media: React.FC = () => {
{/* Debug-Ausgabe entfernt, da ReactNode erwartet wird */} {/* Debug-Ausgabe entfernt, da ReactNode erwartet wird */}
<FileManagerComponent <FileManagerComponent
ref={fileManagerRef} ref={fileManagerRef}
cssClass="e-bigger media-icons-xl"
success={handleSuccess}
fileOpen={handleFileOpen}
ajaxSettings={{ ajaxSettings={{
url: hostUrl + 'operations', url: hostUrl + 'operations',
getImageUrl: hostUrl + 'get-image', getImageUrl: hostUrl + 'get-image',

472
dashboard/src/settings.tsx Normal file
View File

@@ -0,0 +1,472 @@
import React from 'react';
import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
import { getSupplementTableSettings, updateSupplementTableSettings } from './apiSystemSettings';
import { useAuth } from './useAuth';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { Link } from 'react-router-dom';
const Einstellungen: React.FC = () => {
const { user } = useAuth();
const toastRef = React.useRef<ToastComponent>(null);
// Holidays state
const [file, setFile] = React.useState<File | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const [holidays, setHolidays] = React.useState<Holiday[]>([]);
const [periods, setPeriods] = React.useState<AcademicPeriod[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
const periodOptions = React.useMemo(() =>
periods.map(p => ({ id: p.id, name: p.display_name || p.name })),
[periods]
);
// Supplement table state
const [supplementUrl, setSupplementUrl] = React.useState('');
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
const [supplementBusy, setSupplementBusy] = React.useState(false);
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
if (toastRef.current) {
toastRef.current.show({
content,
cssClass,
timeOut: 3000,
});
}
};
const refresh = React.useCallback(async () => {
try {
const data = await listHolidays();
setHolidays(data.holidays);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
setMessage(msg);
showToast(msg, 'e-toast-danger');
}
}, []);
const loadSupplementSettings = React.useCallback(async () => {
try {
const data = await getSupplementTableSettings();
setSupplementUrl(data.url || '');
setSupplementEnabled(data.enabled || false);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Vertretungsplan-Einstellungen';
showToast(msg, 'e-toast-danger');
}
}, []);
const loadAcademicPeriods = React.useCallback(async () => {
try {
const [list, active] = await Promise.all([
listAcademicPeriods(),
getActiveAcademicPeriod(),
]);
setPeriods(list);
setActivePeriodId(active ? active.id : null);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Schuljahre/Perioden';
showToast(msg, 'e-toast-danger');
}
}, []);
React.useEffect(() => {
refresh();
if (user) {
// Academic periods for all users
loadAcademicPeriods();
// System settings only for admin/superadmin (will render only if allowed)
if (['admin', 'superadmin'].includes(user.role)) {
loadSupplementSettings();
}
}
}, [refresh, loadSupplementSettings, loadAcademicPeriods, user]);
const onUpload = async () => {
if (!file) return;
setBusy(true);
setMessage(null);
try {
const res = await uploadHolidaysCsv(file);
const msg = `Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`;
setMessage(msg);
showToast(msg, 'e-toast-success');
await refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
setMessage(msg);
showToast(msg, 'e-toast-danger');
} finally {
setBusy(false);
}
};
const onSaveSupplementSettings = async () => {
setSupplementBusy(true);
try {
await updateSupplementTableSettings(supplementUrl, supplementEnabled);
showToast('Vertretungsplan-Einstellungen erfolgreich gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Einstellungen';
showToast(msg, 'e-toast-danger');
} finally {
setSupplementBusy(false);
}
};
const onTestSupplementUrl = () => {
if (supplementUrl) {
window.open(supplementUrl, '_blank');
} else {
showToast('Bitte geben Sie zuerst eine URL ein', 'e-toast-warning');
}
};
// Determine which tabs to show based on role
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
const isSuperadmin = !!(user && user.role === 'superadmin');
return (
<div style={{ padding: 20 }}>
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
<h2 style={{ marginBottom: 20, fontSize: '24px', fontWeight: 600 }}>Einstellungen</h2>
<TabComponent heightAdjustMode="Auto">
<TabItemsDirective>
{/* 📅 Academic Calendar */}
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={() => (
<div style={{ padding: 20 }}>
{/* Holidays Import */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schulferien importieren</div>
</div>
</div>
<div className="e-card-content">
<p style={{ marginBottom: 12, fontSize: '14px', color: '#666' }}>
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>, <code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>, <strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne Info (ignoriert)
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<input type="file" accept=".csv,text/csv,.txt,text/plain" onChange={e => setFile(e.target.files?.[0] ?? null)} />
<ButtonComponent cssClass="e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</ButtonComponent>
</div>
{message && <div style={{ marginTop: 8, fontSize: '14px' }}>{message}</div>}
</div>
</div>
{/* Imported Holidays List */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Importierte Ferien</div>
</div>
</div>
<div className="e-card-content">
{holidays.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
) : (
<ul style={{ fontSize: '14px', listStyle: 'disc', paddingLeft: 24 }}>
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</div>
</div>
{/* Academic Periods */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Akademische Perioden</div>
</div>
</div>
<div className="e-card-content">
{periods.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Perioden gefunden.</div>
) : (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ minWidth: 260 }}>
<DropDownListComponent
dataSource={periodOptions}
fields={{ text: 'name', value: 'id' }}
value={activePeriodId ?? undefined}
change={(e) => setActivePeriodId(Number(e.value))}
placeholder="Aktive Periode wählen"
popupHeight="250px"
/>
</div>
<ButtonComponent
cssClass="e-primary"
disabled={activePeriodId == null}
onClick={async () => {
if (activePeriodId == null) return;
try {
const p = await setActiveAcademicPeriod(activePeriodId);
showToast(`Aktive Periode gesetzt: ${p.display_name || p.name}`, 'e-toast-success');
await loadAcademicPeriods();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Setzen der aktiven Periode';
showToast(msg, 'e-toast-danger');
}
}}
>
Als aktiv setzen
</ButtonComponent>
</div>
)}
</div>
</div>
</div>
)} />
{/* 🖥️ Display & Clients (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '🖥️ Anzeige & Clients' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Standard-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe).
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Client-Konfiguration</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/clients"><ButtonComponent>Infoscreen-Clients öffnen</ButtonComponent></Link>
<Link to="/infoscr_groups"><ButtonComponent>Raumgruppen öffnen</ButtonComponent></Link>
</div>
</div>
</div>
</div>
)} />
)}
{/* 🎬 Media & Files (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '🎬 Medien & Dateien' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Upload-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte.
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Konvertierungsstatus</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...).
</div>
</div>
</div>
)} />
)}
{/* <20> Events (Admin+): per-event-type defaults and WebUntis link settings */}
{isAdmin && (
<TabItemDirective header={{ text: '<27> Events' }} content={() => (
<div style={{ padding: 20 }}>
{/* WebUntis / Supplement table URL */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">WebUntis / Vertretungsplan</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Vertretungsplan URL
</label>
<TextBoxComponent
placeholder="https://example.com/vertretungsplan"
value={supplementUrl}
change={(e) => setSupplementUrl(e.value || '')}
cssClass="e-outline"
width="100%"
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen.
</div>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Vertretungsplan aktiviert"
checked={supplementEnabled}
change={(e) => setSupplementEnabled(e.checked || false)}
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveSupplementSettings}
disabled={supplementBusy}
>
{supplementBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
<ButtonComponent
cssClass="e-outline"
onClick={onTestSupplementUrl}
disabled={!supplementUrl}
>
Vorschau öffnen
</ButtonComponent>
</div>
</div>
</div>
{/* Presentation defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Präsentationen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Autoplay, Loop, Intervall) für Präsentationen.
</div>
</div>
{/* Website defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Webseiten</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten.
</div>
</div>
{/* Video defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Videos</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Autoplay, Loop, Lautstärke) für Videos.
</div>
</div>
{/* Message defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Mitteilungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen.
</div>
</div>
{/* Other defaults */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Sonstige</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für sonstige Eventtypen.
</div>
</div>
</div>
)} />
)}
{/* 👥 Users (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '👥 Benutzer' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schnellaktionen</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/benutzer"><ButtonComponent cssClass="e-primary">Benutzerverwaltung öffnen</ButtonComponent></Link>
<ButtonComponent disabled title="Demnächst">Benutzer einladen</ButtonComponent>
</div>
</div>
</div>
</div>
)} />
)}
{/* ⚙️ System (Superadmin) */}
{isSuperadmin && (
<TabItemDirective header={{ text: '⚙️ System' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Organisationsinformationen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Organisationsname, Branding, Standard-Lokalisierung.
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Erweiterte Konfiguration</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für System-weit fortgeschrittene Optionen.
</div>
</div>
</div>
)} />
)}
</TabItemsDirective>
</TabComponent>
</div>
);
};
export default Einstellungen;

View File

@@ -0,0 +1,15 @@
/* FileManager icon size overrides (loaded after Syncfusion styles) */
.e-filemanager.media-icons-xl .e-large-icons .e-list-icon {
font-size: 40px; /* default ~24px */
}
.e-filemanager.media-icons-xl .e-large-icons .e-fe-folder,
.e-filemanager.media-icons-xl .e-large-icons .e-fe-file {
font-size: 40px;
}
/* Details (grid) view icons */
.e-filemanager.media-icons-xl .e-fe-grid-icon .e-fe-folder,
.e-filemanager.media-icons-xl .e-fe-grid-icon .e-fe-file {
font-size: 24px;
}

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: [ include: [
'@syncfusion/ej2-react-navigations', '@syncfusion/ej2-react-navigations',
'@syncfusion/ej2-react-buttons', '@syncfusion/ej2-react-buttons',
'@syncfusion/ej2-react-splitbuttons',
'@syncfusion/ej2-base', '@syncfusion/ej2-base',
'@syncfusion/ej2-navigations', '@syncfusion/ej2-navigations',
'@syncfusion/ej2-buttons', '@syncfusion/ej2-buttons',
'@syncfusion/ej2-splitbuttons',
'@syncfusion/ej2-react-base', '@syncfusion/ej2-react-base',
], ],
// 🔧 NEU: Force dependency re-optimization // 🔧 NEU: Force dependency re-optimization

View File

@@ -75,9 +75,12 @@ services:
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
DB_HOST: db DB_HOST: db
FLASK_ENV: production FLASK_ENV: production
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY}
MQTT_BROKER_URL: mqtt://mqtt:1883 MQTT_BROKER_URL: mqtt://mqtt:1883
MQTT_USER: ${MQTT_USER} MQTT_USER: ${MQTT_USER}
MQTT_PASSWORD: ${MQTT_PASSWORD} MQTT_PASSWORD: ${MQTT_PASSWORD}
DEFAULT_SUPERADMIN_USERNAME: ${DEFAULT_SUPERADMIN_USERNAME:-superadmin}
DEFAULT_SUPERADMIN_PASSWORD: ${DEFAULT_SUPERADMIN_PASSWORD}
networks: networks:
- infoscreen-net - infoscreen-net
healthcheck: healthcheck:

View File

@@ -18,6 +18,10 @@ services:
environment: environment:
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} - DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
- DB_URL=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 # 🔧 ENTFERNT: Volume-Mount ist nur für die Entwicklung
networks: networks:
- infoscreen-net - infoscreen-net

View File

@@ -10,6 +10,7 @@ Base = declarative_base()
class UserRole(enum.Enum): class UserRole(enum.Enum):
user = "user" user = "user"
editor = "editor"
admin = "admin" admin = "admin"
superadmin = "superadmin" superadmin = "superadmin"
@@ -284,3 +285,23 @@ class Conversion(Base):
UniqueConstraint('source_event_media_id', 'target_format', UniqueConstraint('source_event_media_id', 'target_format',
'file_hash', name='uq_conv_source_target_hash'), 'file_hash', name='uq_conv_source_target_hash'),
) )
# --- SystemSetting: Flexible key-value store for system-wide configuration ---
class SystemSetting(Base):
__tablename__ = 'system_settings'
key = Column(String(100), primary_key=True, nullable=False)
value = Column(Text, nullable=True)
description = Column(String(255), nullable=True)
updated_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp(),
onupdate=func.current_timestamp())
def to_dict(self):
return {
"key": self.key,
"value": self.value,
"description": self.description,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -2,22 +2,45 @@
from dotenv import load_dotenv from dotenv import load_dotenv
import os import os
from datetime import datetime from datetime import datetime
import logging
from sqlalchemy.orm import sessionmaker, joinedload from sqlalchemy.orm import sessionmaker, joinedload
from sqlalchemy import create_engine from sqlalchemy import create_engine, or_, and_, text
from models.models import Event, EventMedia, EventException from models.models import Event, EventMedia, EventException
from dateutil.rrule import rrulestr from dateutil.rrule import rrulestr
from datetime import timezone from datetime import timezone
# Load .env only in development to mirror server/database.py behavior
if os.getenv("ENV", "development") == "development":
# Expect .env at workspace root
load_dotenv('/workspace/.env') load_dotenv('/workspace/.env')
# DB-URL aus Umgebungsvariable oder Fallback # DB-URL aus Umgebungsvariable oder Fallback wie im Server
DB_CONN = os.environ.get("DB_CONN", "mysql+pymysql://user:password@db/dbname") DB_URL = os.environ.get("DB_CONN")
engine = create_engine(DB_CONN) if not DB_URL:
DB_USER = os.environ.get("DB_USER", "infoscreen_admin")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "KqtpM7wmNd&mFKs")
DB_HOST = os.environ.get("DB_HOST", "db")
DB_NAME = os.environ.get("DB_NAME", "infoscreen_by_taa")
DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"
print(f"[Scheduler] Using DB_URL: {DB_URL}")
engine = create_engine(DB_URL)
# Proactive connectivity check to surface errors early
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("[Scheduler] DB connectivity OK")
except Exception as db_exc:
print(f"[Scheduler] DB connectivity FAILED: {db_exc}")
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
# Base URL from .env for file URLs # Base URL from .env for file URLs
API_BASE_URL = os.environ.get("API_BASE_URL", "http://server:8000") API_BASE_URL = os.environ.get("API_BASE_URL", "http://server:8000")
# Cache conversion decisions per media to avoid repeated lookups/logs within the scheduler lifetime
_media_conversion_cache = {} # media_id -> pdf_url or None
_media_decision_logged = set() # media_id(s) already logged
def get_active_events(start: datetime, end: datetime, group_id: int = None): def get_active_events(start: datetime, end: datetime, group_id: int = None):
session = Session() session = Session()
@@ -28,21 +51,83 @@ def get_active_events(start: datetime, end: datetime, group_id: int = None):
).filter(Event.is_active == True) ).filter(Event.is_active == True)
if start and end: if start and end:
query = query.filter(Event.start < end, Event.end > start) # Include:
# 1) Non-recurring events that overlap [start, end]
# 2) Recurring events whose recurrence window intersects [start, end]
# We consider dtstart (Event.start) <= end and (recurrence_end is NULL or >= start)
non_recurring_overlap = and_(
Event.recurrence_rule == None,
Event.start < end,
Event.end > start,
)
recurring_window = and_(
Event.recurrence_rule != None,
Event.start <= end,
or_(Event.recurrence_end == None, Event.recurrence_end >= start),
)
query = query.filter(or_(non_recurring_overlap, recurring_window))
if group_id: if group_id:
query = query.filter(Event.group_id == group_id) query = query.filter(Event.group_id == group_id)
# Log base event count before expansion
try:
base_count = query.count()
# Additional diagnostics: split counts
non_rec_q = session.query(Event.id).filter(Event.is_active == True)
rec_q = session.query(Event.id).filter(Event.is_active == True)
if start and end:
non_rec_q = non_rec_q.filter(non_recurring_overlap)
rec_q = rec_q.filter(recurring_window)
if group_id:
non_rec_q = non_rec_q.filter(Event.group_id == group_id)
rec_q = rec_q.filter(Event.group_id == group_id)
non_rec_count = non_rec_q.count()
rec_count = rec_q.count()
logging.debug(f"[Scheduler] Base events total={base_count} non_recurring_overlap={non_rec_count} recurring_window={rec_count}")
except Exception:
base_count = None
events = query.all() events = query.all()
logging.debug(f"[Scheduler] Base events fetched: {len(events)} (count={base_count})")
if len(events) == 0:
# Quick probe: are there any active events at all?
try:
any_active = session.query(Event).filter(Event.is_active == True).count()
logging.info(f"[Scheduler] Active events in DB (any group, any time): {any_active}")
except Exception as e:
logging.warning(f"[Scheduler] Could not count active events: {e}")
formatted_events = [] formatted_events = []
for event in events: for event in events:
# If event has RRULE, expand into instances within [start, end] # If event has RRULE, expand into instances within [start, end]
if event.recurrence_rule: if event.recurrence_rule:
try: try:
r = rrulestr(event.recurrence_rule, dtstart=event.start) # Ensure dtstart is timezone-aware (UTC if naive)
dtstart = event.start
if dtstart.tzinfo is None:
dtstart = dtstart.replace(tzinfo=timezone.utc)
r = rrulestr(event.recurrence_rule, dtstart=dtstart)
# Ensure query bounds are timezone-aware
query_start = start if start.tzinfo else start.replace(tzinfo=timezone.utc)
query_end = end if end.tzinfo else end.replace(tzinfo=timezone.utc)
# Clamp by recurrence_end if present
if getattr(event, "recurrence_end", None):
rec_end = event.recurrence_end
if rec_end and rec_end.tzinfo is None:
rec_end = rec_end.replace(tzinfo=timezone.utc)
if rec_end and rec_end < query_end:
query_end = rec_end
# iterate occurrences within range # iterate occurrences within range
occ_starts = r.between(start, end, inc=True) # Use a lookback equal to the event's duration to catch occurrences that started
# before query_start but are still running within the window.
duration = (event.end - event.start) if (event.end and event.start) else None duration = (event.end - event.start) if (event.end and event.start) else None
lookback_start = query_start
if duration:
lookback_start = query_start - duration
occ_starts = r.between(lookback_start, query_end, inc=True)
for occ_start in occ_starts: for occ_start in occ_starts:
occ_end = (occ_start + duration) if duration else occ_start occ_end = (occ_start + duration) if duration else occ_start
# Apply exceptions # Apply exceptions
@@ -57,13 +142,22 @@ def get_active_events(start: datetime, end: datetime, group_id: int = None):
occ_start = exc.override_start occ_start = exc.override_start
if exc.override_end: if exc.override_end:
occ_end = exc.override_end occ_end = exc.override_end
# Filter out instances that do not overlap [start, end]
if not (occ_start < end and occ_end > start):
continue
inst = format_event_with_media(event) inst = format_event_with_media(event)
# Apply overrides to title/description if provided
if exc and exc.override_title:
inst["title"] = exc.override_title
if exc and exc.override_description:
inst["description"] = exc.override_description
inst["start"] = occ_start.isoformat() inst["start"] = occ_start.isoformat()
inst["end"] = occ_end.isoformat() inst["end"] = occ_end.isoformat()
inst["occurrence_of_id"] = event.id inst["occurrence_of_id"] = event.id
formatted_events.append(inst) formatted_events.append(inst)
except Exception: except Exception as e:
# On parse error, fall back to single event formatting # On parse error, fall back to single event formatting
logging.warning(f"Failed to parse recurrence rule for event {event.id}: {e}")
formatted_events.append(format_event_with_media(event)) formatted_events.append(format_event_with_media(event))
else: else:
formatted_events.append(format_event_with_media(event)) formatted_events.append(format_event_with_media(event))
@@ -87,7 +181,6 @@ def format_event_with_media(event):
} }
# Now you can directly access event.event_media # Now you can directly access event.event_media
import logging
if event.event_media: if event.event_media:
media = event.event_media media = event.event_media
@@ -99,16 +192,16 @@ def format_event_with_media(event):
"auto_advance": True "auto_advance": True
} }
# Debug: log media_type # Avoid per-call media-type debug to reduce log noise
logging.debug(
f"[Scheduler] EventMedia id={media.id} media_type={getattr(media.media_type, 'value', str(media.media_type))}")
# Check for PDF conversion for ppt/pptx/odp # Decide file URL with caching to avoid repeated DB lookups/logs
pdf_url = _media_conversion_cache.get(media.id, None)
if pdf_url is None and getattr(media.media_type, 'value', str(media.media_type)) in ("ppt", "pptx", "odp"):
from sqlalchemy.orm import scoped_session from sqlalchemy.orm import scoped_session
from models.models import Conversion, ConversionStatus from models.models import Conversion, ConversionStatus
session = scoped_session(Session) session = scoped_session(Session)
pdf_url = None try:
if getattr(media.media_type, 'value', str(media.media_type)) in ("ppt", "pptx", "odp"):
conversion = session.query(Conversion).filter_by( conversion = session.query(Conversion).filter_by(
source_event_media_id=media.id, source_event_media_id=media.id,
target_format="pdf", target_format="pdf",
@@ -117,10 +210,13 @@ def format_event_with_media(event):
logging.debug( logging.debug(
f"[Scheduler] Conversion lookup for media_id={media.id}: found={bool(conversion)}, path={getattr(conversion, 'target_path', None) if conversion else None}") f"[Scheduler] Conversion lookup for media_id={media.id}: found={bool(conversion)}, path={getattr(conversion, 'target_path', None) if conversion else None}")
if conversion and conversion.target_path: if conversion and conversion.target_path:
# Serve via /api/files/converted/<path>
pdf_url = f"{API_BASE_URL}/api/files/converted/{conversion.target_path}" pdf_url = f"{API_BASE_URL}/api/files/converted/{conversion.target_path}"
finally:
session.remove() session.remove()
# Cache the decision (even if None) to avoid repeated lookups in the same run
_media_conversion_cache[media.id] = pdf_url
# Build file entry and log decision only once per media
if pdf_url: if pdf_url:
filename = os.path.basename(pdf_url) filename = os.path.basename(pdf_url)
event_dict["presentation"]["files"].append({ event_dict["presentation"]["files"].append({
@@ -129,8 +225,10 @@ def format_event_with_media(event):
"checksum": None, "checksum": None,
"size": None "size": None
}) })
logging.info( if media.id not in _media_decision_logged:
logging.debug(
f"[Scheduler] Using converted PDF for event_media_id={media.id}: {pdf_url}") f"[Scheduler] Using converted PDF for event_media_id={media.id}: {pdf_url}")
_media_decision_logged.add(media.id)
elif media.file_path: elif media.file_path:
filename = os.path.basename(media.file_path) filename = os.path.basename(media.file_path)
event_dict["presentation"]["files"].append({ event_dict["presentation"]["files"].append({
@@ -139,8 +237,10 @@ def format_event_with_media(event):
"checksum": None, "checksum": None,
"size": None "size": None
}) })
logging.info( if media.id not in _media_decision_logged:
logging.debug(
f"[Scheduler] Using original file for event_media_id={media.id}: {filename}") f"[Scheduler] Using original file for event_media_id={media.id}: {filename}")
_media_decision_logged.add(media.id)
# Add other event types... # Add other event types...

View File

@@ -37,6 +37,8 @@ def main():
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
# 0 = aus; z.B. 600 für alle 10 Min # 0 = aus; z.B. 600 für alle 10 Min
REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0")) REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0"))
# Konfigurierbares Zeitfenster in Tagen (Standard: 7)
WINDOW_DAYS = int(os.getenv("EVENTS_WINDOW_DAYS", "7"))
last_payloads = {} # group_id -> payload last_payloads = {} # group_id -> payload
last_published_at = {} # group_id -> epoch seconds last_published_at = {} # group_id -> epoch seconds
@@ -55,8 +57,17 @@ def main():
while True: while True:
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
# Query window: next N days to capture upcoming events and recurring instances
# Clients need to know what's coming, not just what's active right now
end_window = now + datetime.timedelta(days=WINDOW_DAYS)
logging.debug(f"Fetching events window start={now.isoformat()} end={end_window.isoformat()} (days={WINDOW_DAYS})")
# Hole alle aktiven Events (bereits formatierte Dictionaries) # Hole alle aktiven Events (bereits formatierte Dictionaries)
events = get_active_events(now, now) try:
events = get_active_events(now, end_window)
logging.debug(f"Fetched {len(events)} events for publishing window")
except Exception as e:
logging.exception(f"Error while fetching events: {e}")
events = []
# Gruppiere Events nach group_id # Gruppiere Events nach group_id
groups = {} groups = {}
@@ -67,6 +78,9 @@ def main():
# Event ist bereits ein Dictionary im gewünschten Format # Event ist bereits ein Dictionary im gewünschten Format
groups[gid].append(event) groups[gid].append(event)
if not groups:
logging.debug("No events grouped for any client group in current window")
# Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung # Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung
for gid, event_list in groups.items(): for gid, event_list in groups.items():
# stabile Reihenfolge, um unnötige Publishes zu vermeiden # stabile Reihenfolge, um unnötige Publishes zu vermeiden
@@ -87,7 +101,7 @@ def main():
logging.error( logging.error(
f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}") f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}")
else: else:
logging.info(f"Events für Gruppe {gid} gesendet") logging.info(f"Events für Gruppe {gid} gesendet (count={len(event_list)})")
last_payloads[gid] = payload last_payloads[gid] = payload
last_published_at[gid] = time.time() last_published_at[gid] = time.time()

View File

@@ -0,0 +1,37 @@
"""add_system_settings_table
Revision ID: 045626c9719a
Revises: 488ce87c28ae
Create Date: 2025-10-16 18:38:47.415244
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '045626c9719a'
down_revision: Union[str, None] = '488ce87c28ae'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
'system_settings',
sa.Column('key', sa.String(100), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.Column('description', sa.String(255), nullable=True),
sa.Column('updated_at', sa.TIMESTAMP(timezone=True),
server_default=sa.func.current_timestamp(),
nullable=True),
sa.PrimaryKeyConstraint('key')
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('system_settings')

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.") print("✅ Default-Gruppe mit id=1 angelegt.")
# Admin-Benutzer anlegen, falls nicht vorhanden # Superadmin-Benutzer anlegen, falls nicht vorhanden
admin_user = os.getenv("DEFAULT_ADMIN_USERNAME", "infoscreen_admin") admin_user = os.getenv("DEFAULT_SUPERADMIN_USERNAME", "superadmin")
admin_pw = os.getenv("DEFAULT_ADMIN_PASSWORD", "Info_screen_admin25!") 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 # Passwort hashen mit bcrypt
hashed_pw = bcrypt.hashpw(admin_pw.encode( hashed_pw = bcrypt.hashpw(admin_pw.encode(
'utf-8'), bcrypt.gensalt()).decode('utf-8') 'utf-8'), bcrypt.gensalt()).decode('utf-8')
@@ -30,9 +34,33 @@ with engine.connect() as conn:
result = conn.execute(text( result = conn.execute(text(
"SELECT COUNT(*) FROM users WHERE username=:username"), {"username": admin_user}) "SELECT COUNT(*) FROM users WHERE username=:username"), {"username": admin_user})
if result.scalar() == 0: if result.scalar() == 0:
# Rolle: 1 = Admin (ggf. anpassen je nach Modell) # Rolle: 'superadmin' gemäß UserRole enum
conn.execute( 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} {"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.")
# Default System Settings anlegen
default_settings = [
('supplement_table_url', '', 'URL für Vertretungsplan (Stundenplan-Änderungstabelle)'),
('supplement_table_enabled', 'false', 'Ob Vertretungsplan aktiviert ist'),
]
for key, value, description in default_settings:
result = conn.execute(
text("SELECT COUNT(*) FROM system_settings WHERE `key`=:key"),
{"key": key}
)
if result.scalar() == 0:
conn.execute(
text("INSERT INTO system_settings (`key`, value, description) VALUES (:key, :value, :description)"),
{"key": key, "value": value, "description": description}
)
print(f"✅ System-Einstellung '{key}' angelegt.")
else:
print(f" System-Einstellung '{key}' existiert bereits.")
print("✅ Initialisierung abgeschlossen.")

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

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

@@ -0,0 +1,218 @@
"""
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
# For SQLAlchemy Enum(UserRole), ensure we return the string value
role_value = user.role.value if isinstance(user.role, UserRole) else str(user.role)
return jsonify({
"id": user.id,
"username": user.username,
"role": role_value,
"is_active": user.is_active
}), 200
except Exception as e:
# Avoid naked 500s; return a JSON error with minimal info (safe in dev)
env = os.environ.get("ENV", "production").lower()
msg = str(e) if env in ("development", "dev") else "Internal server error"
return jsonify({"error": msg}), 500
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 server.database import Session
from models.models import Client, ClientGroup from models.models import Client, ClientGroup
from flask import Blueprint, request, jsonify 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 from server.mqtt_helper import publish_client_group, delete_client_group_message, publish_multiple_client_groups
import sys import sys
sys.path.append('/workspace') 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"]) @clients_bp.route("/sync-all-groups", methods=["POST"])
@admin_or_higher
def sync_all_client_groups(): def sync_all_client_groups():
""" """
Administrative Route: Synchronisiert alle bestehenden Client-Gruppenzuordnungen mit MQTT 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"]) @clients_bp.route("/<uuid>/description", methods=["PUT"])
@admin_or_higher
def set_client_description(uuid): def set_client_description(uuid):
data = request.get_json() data = request.get_json()
description = data.get("description", "").strip() description = data.get("description", "").strip()
@@ -127,6 +130,7 @@ def get_clients():
@clients_bp.route("/group", methods=["PUT"]) @clients_bp.route("/group", methods=["PUT"])
@admin_or_higher
def update_clients_group(): def update_clients_group():
data = request.get_json() data = request.get_json()
client_ids = data.get("client_ids", []) client_ids = data.get("client_ids", [])
@@ -178,6 +182,7 @@ def update_clients_group():
@clients_bp.route("/<uuid>", methods=["PATCH"]) @clients_bp.route("/<uuid>", methods=["PATCH"])
@admin_or_higher
def update_client(uuid): def update_client(uuid):
data = request.get_json() data = request.get_json()
session = Session() session = Session()
@@ -234,6 +239,7 @@ def get_clients_with_alive_status():
@clients_bp.route("/<uuid>/restart", methods=["POST"]) @clients_bp.route("/<uuid>/restart", methods=["POST"])
@admin_or_higher
def restart_client(uuid): def restart_client(uuid):
""" """
Route to restart a specific client by UUID. Route to restart a specific client by UUID.
@@ -268,6 +274,7 @@ def restart_client(uuid):
@clients_bp.route("/<uuid>", methods=["DELETE"]) @clients_bp.route("/<uuid>", methods=["DELETE"])
@admin_or_higher
def delete_client(uuid): def delete_client(uuid):
session = Session() session = Session()
client = session.query(Client).filter_by(uuid=uuid).first() client = session.query(Client).filter_by(uuid=uuid).first()

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
from re import A from re import A
from flask import Blueprint, request, jsonify, send_from_directory from flask import Blueprint, request, jsonify, send_from_directory
from server.permissions import editor_or_higher
from server.database import Session from server.database import Session
from models.models import EventMedia, MediaType, Conversion, ConversionStatus from models.models import EventMedia, MediaType, Conversion, ConversionStatus
from server.task_queue import get_queue 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']) @eventmedia_bp.route('/filemanager/operations', methods=['GET', 'POST'])
@editor_or_higher
def filemanager_operations(): def filemanager_operations():
action = get_param('action') action = get_param('action')
path = get_param('path', '/') path = get_param('path', '/')
@@ -36,7 +38,18 @@ def filemanager_operations():
print(action, path, name, new_name, target_path, full_path) # Debug-Ausgabe print(action, path, name, new_name, target_path, full_path) # Debug-Ausgabe
# Superadmin-only protection for the converted folder
from flask import session as flask_session
user_role = flask_session.get('role')
is_superadmin = user_role == 'superadmin'
# Normalize path for checks
norm_path = os.path.normpath('/' + path.lstrip('/'))
under_converted = norm_path == '/converted' or norm_path.startswith('/converted/')
if action == 'read': if action == 'read':
# Block listing inside converted for non-superadmins
if under_converted and not is_superadmin:
return jsonify({'files': [], 'cwd': {'name': os.path.basename(full_path), 'path': path}})
# List files and folders # List files and folders
items = [] items = []
session = Session() session = Session()
@@ -59,6 +72,8 @@ def filemanager_operations():
item['dateModified'] = entry.stat().st_mtime item['dateModified'] = entry.stat().st_mtime
else: else:
item['dateModified'] = entry.stat().st_mtime item['dateModified'] = entry.stat().st_mtime
# Hide the converted folder at root for non-superadmins
if not (not is_superadmin and not entry.is_file() and entry.name == 'converted' and (norm_path == '/' or norm_path == '')):
items.append(item) items.append(item)
session.close() session.close()
return jsonify({'files': items, 'cwd': {'name': os.path.basename(full_path), 'path': path}}) return jsonify({'files': items, 'cwd': {'name': os.path.basename(full_path), 'path': path}})
@@ -88,6 +103,8 @@ def filemanager_operations():
session.close() session.close()
return jsonify({'details': details}) return jsonify({'details': details})
elif action == 'delete': elif action == 'delete':
if under_converted and not is_superadmin:
return jsonify({'error': 'Insufficient permissions'}), 403
for item in request.form.getlist('names[]'): for item in request.form.getlist('names[]'):
item_path = os.path.join(full_path, item) item_path = os.path.join(full_path, item)
if os.path.isdir(item_path): if os.path.isdir(item_path):
@@ -96,16 +113,23 @@ def filemanager_operations():
os.remove(item_path) os.remove(item_path)
return jsonify({'success': True}) return jsonify({'success': True})
elif action == 'rename': elif action == 'rename':
if under_converted and not is_superadmin:
return jsonify({'error': 'Insufficient permissions'}), 403
src = os.path.join(full_path, name) src = os.path.join(full_path, name)
dst = os.path.join(full_path, new_name) dst = os.path.join(full_path, new_name)
os.rename(src, dst) os.rename(src, dst)
return jsonify({'success': True}) return jsonify({'success': True})
elif action == 'move': elif action == 'move':
# Prevent moving into converted if not superadmin
if (target_path and target_path.strip('/').split('/')[0] == 'converted') and not is_superadmin:
return jsonify({'error': 'Insufficient permissions'}), 403
src = os.path.join(full_path, name) src = os.path.join(full_path, name)
dst = os.path.join(MEDIA_ROOT, target_path.lstrip('/'), name) dst = os.path.join(MEDIA_ROOT, target_path.lstrip('/'), name)
os.rename(src, dst) os.rename(src, dst)
return jsonify({'success': True}) return jsonify({'success': True})
elif action == 'create': elif action == 'create':
if under_converted and not is_superadmin:
return jsonify({'error': 'Insufficient permissions'}), 403
os.makedirs(os.path.join(full_path, name), exist_ok=True) os.makedirs(os.path.join(full_path, name), exist_ok=True)
return jsonify({'success': True}) return jsonify({'success': True})
else: else:
@@ -115,10 +139,17 @@ def filemanager_operations():
@eventmedia_bp.route('/filemanager/upload', methods=['POST']) @eventmedia_bp.route('/filemanager/upload', methods=['POST'])
@editor_or_higher
def filemanager_upload(): def filemanager_upload():
session = Session() session = Session()
# Korrigiert: Erst aus request.form, dann aus request.args lesen # Korrigiert: Erst aus request.form, dann aus request.args lesen
path = request.form.get('path') or request.args.get('path', '/') path = request.form.get('path') or request.args.get('path', '/')
from flask import session as flask_session
user_role = flask_session.get('role')
is_superadmin = user_role == 'superadmin'
norm_path = os.path.normpath('/' + path.lstrip('/'))
if (norm_path == '/converted' or norm_path.startswith('/converted/')) and not is_superadmin:
return jsonify({'error': 'Insufficient permissions'}), 403
upload_path = os.path.join(MEDIA_ROOT, path.lstrip('/')) upload_path = os.path.join(MEDIA_ROOT, path.lstrip('/'))
os.makedirs(upload_path, exist_ok=True) os.makedirs(upload_path, exist_ok=True)
for file in request.files.getlist('uploadFiles'): for file in request.files.getlist('uploadFiles'):
@@ -181,9 +212,16 @@ def filemanager_upload():
@eventmedia_bp.route('/filemanager/download', methods=['GET']) @eventmedia_bp.route('/filemanager/download', methods=['GET'])
def filemanager_download(): def filemanager_download():
path = request.args.get('path', '/') path = request.args.get('path', '/')
from flask import session as flask_session
user_role = flask_session.get('role')
is_superadmin = user_role == 'superadmin'
norm_path = os.path.normpath('/' + path.lstrip('/'))
names = request.args.getlist('names[]') names = request.args.getlist('names[]')
# Nur Einzel-Download für Beispiel # Nur Einzel-Download für Beispiel
if names: if names:
# Block access to converted for non-superadmins
if (norm_path == '/converted' or norm_path.startswith('/converted/')) and not is_superadmin:
return jsonify({'error': 'Insufficient permissions'}), 403
file_path = os.path.join(MEDIA_ROOT, path.lstrip('/'), names[0]) file_path = os.path.join(MEDIA_ROOT, path.lstrip('/'), names[0])
return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), as_attachment=True) return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), as_attachment=True)
return jsonify({'error': 'No file specified'}), 400 return jsonify({'error': 'No file specified'}), 400
@@ -194,6 +232,12 @@ def filemanager_download():
@eventmedia_bp.route('/filemanager/get-image', methods=['GET']) @eventmedia_bp.route('/filemanager/get-image', methods=['GET'])
def filemanager_get_image(): def filemanager_get_image():
path = request.args.get('path', '/') path = request.args.get('path', '/')
from flask import session as flask_session
user_role = flask_session.get('role')
is_superadmin = user_role == 'superadmin'
norm_path = os.path.normpath('/' + path.lstrip('/'))
if (norm_path == '/converted' or norm_path.startswith('/converted/')) and not is_superadmin:
return jsonify({'error': 'Insufficient permissions'}), 403
file_path = os.path.join(MEDIA_ROOT, path.lstrip('/')) file_path = os.path.join(MEDIA_ROOT, path.lstrip('/'))
return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path)) return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path))
@@ -210,6 +254,7 @@ def list_media():
@eventmedia_bp.route('/<int:media_id>', methods=['PUT']) @eventmedia_bp.route('/<int:media_id>', methods=['PUT'])
@editor_or_higher
def update_media(media_id): def update_media(media_id):
session = Session() session = Session()
media = session.query(EventMedia).get(media_id) media = session.query(EventMedia).get(media_id)

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from server.permissions import editor_or_higher
from server.database import Session from server.database import Session
from models.models import Event, EventMedia, MediaType, EventException from models.models import Event, EventMedia, MediaType, EventException
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -47,8 +48,20 @@ def get_events():
else: else:
end_dt = e.end end_dt = e.end
# Setze is_active auf False, wenn Termin vorbei ist # Auto-deactivate only non-recurring events past their end.
if end_dt and end_dt < now and e.is_active: # Recurring events remain active until their RecurrenceEnd (UNTIL) has passed.
if e.is_active:
if e.recurrence_rule:
# For recurring, deactivate only when recurrence_end exists and is in the past
rec_end = e.recurrence_end
if rec_end and rec_end.tzinfo is None:
rec_end = rec_end.replace(tzinfo=timezone.utc)
if rec_end and rec_end < now:
e.is_active = False
session.commit()
else:
# Non-recurring: deactivate when end is in the past
if end_dt and end_dt < now:
e.is_active = False e.is_active = False
session.commit() session.commit()
if not (show_inactive or e.is_active): if not (show_inactive or e.is_active):
@@ -140,6 +153,7 @@ def get_event(event_id):
@events_bp.route("/<event_id>", methods=["DELETE"]) # delete series or single event @events_bp.route("/<event_id>", methods=["DELETE"]) # delete series or single event
@editor_or_higher
def delete_event(event_id): def delete_event(event_id):
session = Session() session = Session()
event = session.query(Event).filter_by(id=event_id).first() event = session.query(Event).filter_by(id=event_id).first()
@@ -162,7 +176,7 @@ def delete_event(event_id):
@events_bp.route("/<event_id>/occurrences/<occurrence_date>", methods=["DELETE"]) # skip single occurrence @events_bp.route("/<event_id>/occurrences/<occurrence_date>", methods=["DELETE"]) # skip single occurrence
@editor_or_higher
def delete_event_occurrence(event_id, occurrence_date): def delete_event_occurrence(event_id, occurrence_date):
"""Delete a single occurrence of a recurring event by creating an EventException.""" """Delete a single occurrence of a recurring event by creating an EventException."""
session = Session() session = Session()
@@ -217,6 +231,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 @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): def detach_event_occurrence(event_id, occurrence_date):
"""BULLETPROOF: Detach single occurrence without touching master event.""" """BULLETPROOF: Detach single occurrence without touching master event."""
session = Session() session = Session()
@@ -322,6 +337,7 @@ def detach_event_occurrence(event_id, occurrence_date):
@events_bp.route("", methods=["POST"]) @events_bp.route("", methods=["POST"])
@editor_or_higher
def create_event(): def create_event():
data = request.json data = request.json
session = Session() session = Session()
@@ -438,6 +454,7 @@ def create_event():
@events_bp.route("/<event_id>", methods=["PUT"]) # update series or single event @events_bp.route("/<event_id>", methods=["PUT"]) # update series or single event
@editor_or_higher
def update_event(event_id): def update_event(event_id):
data = request.json data = request.json
session = Session() session = Session()

View File

@@ -4,6 +4,7 @@ from models.models import Client
from server.database import Session from server.database import Session
from models.models import ClientGroup from models.models import ClientGroup
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from server.permissions import admin_or_higher, require_role
from sqlalchemy import func from sqlalchemy import func
import sys import sys
import os import os
@@ -15,11 +16,15 @@ groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
def get_grace_period(): def get_grace_period():
"""Wählt die Grace-Periode abhängig von ENV.""" """Wählt die Grace-Periode abhängig von ENV.
Clients send heartbeats every ~65s. Grace period allows 2 missed
heartbeats plus safety margin before marking offline.
"""
env = os.environ.get("ENV", "production").lower() env = os.environ.get("ENV", "production").lower()
if env == "development" or env == "dev": if env == "development" or env == "dev":
return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_DEV", "15")) return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_DEV", "180"))
return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_PROD", "180")) return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_PROD", "170"))
def is_client_alive(last_alive, is_active): def is_client_alive(last_alive, is_active):
@@ -41,6 +46,7 @@ def is_client_alive(last_alive, is_active):
@groups_bp.route("", methods=["POST"]) @groups_bp.route("", methods=["POST"])
@admin_or_higher
def create_group(): def create_group():
data = request.get_json() data = request.get_json()
name = data.get("name") name = data.get("name")
@@ -83,6 +89,7 @@ def get_groups():
@groups_bp.route("/<int:group_id>", methods=["PUT"]) @groups_bp.route("/<int:group_id>", methods=["PUT"])
@admin_or_higher
def update_group(group_id): def update_group(group_id):
data = request.get_json() data = request.get_json()
session = Session() session = Session()
@@ -106,6 +113,7 @@ def update_group(group_id):
@groups_bp.route("/<int:group_id>", methods=["DELETE"]) @groups_bp.route("/<int:group_id>", methods=["DELETE"])
@admin_or_higher
def delete_group(group_id): def delete_group(group_id):
session = Session() session = Session()
group = session.query(ClientGroup).filter_by(id=group_id).first() group = session.query(ClientGroup).filter_by(id=group_id).first()
@@ -119,6 +127,7 @@ def delete_group(group_id):
@groups_bp.route("/byname/<string:group_name>", methods=["DELETE"]) @groups_bp.route("/byname/<string:group_name>", methods=["DELETE"])
@admin_or_higher
def delete_group_by_name(group_name): def delete_group_by_name(group_name):
session = Session() session = Session()
group = session.query(ClientGroup).filter_by(name=group_name).first() group = session.query(ClientGroup).filter_by(name=group_name).first()
@@ -132,6 +141,7 @@ def delete_group_by_name(group_name):
@groups_bp.route("/byname/<string:old_name>", methods=["PUT"]) @groups_bp.route("/byname/<string:old_name>", methods=["PUT"])
@admin_or_higher
def rename_group_by_name(old_name): def rename_group_by_name(old_name):
data = request.get_json() data = request.get_json()
new_name = data.get("newName") new_name = data.get("newName")

View File

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

View File

@@ -0,0 +1,203 @@
"""
System Settings API endpoints.
Provides key-value storage for system-wide configuration.
"""
from flask import Blueprint, jsonify, request
from server.database import Session
from models.models import SystemSetting
from server.permissions import admin_or_higher
from sqlalchemy.exc import SQLAlchemyError
system_settings_bp = Blueprint('system_settings', __name__, url_prefix='/api/system-settings')
@system_settings_bp.route('', methods=['GET'])
@admin_or_higher
def get_all_settings():
"""
Get all system settings.
Admin+ only.
"""
session = Session()
try:
settings = session.query(SystemSetting).all()
return jsonify({
'settings': [s.to_dict() for s in settings]
}), 200
except SQLAlchemyError as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/<key>', methods=['GET'])
@admin_or_higher
def get_setting(key):
"""
Get a specific system setting by key.
Admin+ only.
"""
session = Session()
try:
setting = session.query(SystemSetting).filter_by(key=key).first()
if not setting:
return jsonify({'error': 'Setting not found'}), 404
return jsonify(setting.to_dict()), 200
except SQLAlchemyError as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/<key>', methods=['POST', 'PUT'])
@admin_or_higher
def update_setting(key):
"""
Create or update a system setting.
Admin+ only.
Request body:
{
"value": "string",
"description": "string" (optional)
}
"""
session = Session()
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
value = data.get('value')
description = data.get('description')
# Try to find existing setting
setting = session.query(SystemSetting).filter_by(key=key).first()
if setting:
# Update existing
setting.value = value
if description is not None:
setting.description = description
else:
# Create new
setting = SystemSetting(
key=key,
value=value,
description=description
)
session.add(setting)
session.commit()
return jsonify(setting.to_dict()), 200
except SQLAlchemyError as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/<key>', methods=['DELETE'])
@admin_or_higher
def delete_setting(key):
"""
Delete a system setting.
Admin+ only.
"""
session = Session()
try:
setting = session.query(SystemSetting).filter_by(key=key).first()
if not setting:
return jsonify({'error': 'Setting not found'}), 404
session.delete(setting)
session.commit()
return jsonify({'message': 'Setting deleted successfully'}), 200
except SQLAlchemyError as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
# Convenience endpoints for specific settings
@system_settings_bp.route('/supplement-table', methods=['GET'])
@admin_or_higher
def get_supplement_table_settings():
"""
Get supplement table URL and enabled status.
Admin+ only.
"""
session = Session()
try:
url_setting = session.query(SystemSetting).filter_by(key='supplement_table_url').first()
enabled_setting = session.query(SystemSetting).filter_by(key='supplement_table_enabled').first()
return jsonify({
'url': url_setting.value if url_setting else '',
'enabled': enabled_setting.value == 'true' if enabled_setting else False,
}), 200
except SQLAlchemyError as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/supplement-table', methods=['POST'])
@admin_or_higher
def update_supplement_table_settings():
"""
Update supplement table URL and enabled status.
Admin+ only.
Request body:
{
"url": "https://...",
"enabled": true/false
}
"""
session = Session()
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
url = data.get('url', '')
enabled = data.get('enabled', False)
# Update or create URL setting
url_setting = session.query(SystemSetting).filter_by(key='supplement_table_url').first()
if url_setting:
url_setting.value = url
else:
url_setting = SystemSetting(
key='supplement_table_url',
value=url,
description='URL für Vertretungsplan (Stundenplan-Änderungstabelle)'
)
session.add(url_setting)
# Update or create enabled setting
enabled_setting = session.query(SystemSetting).filter_by(key='supplement_table_enabled').first()
if enabled_setting:
enabled_setting.value = 'true' if enabled else 'false'
else:
enabled_setting = SystemSetting(
key='supplement_table_enabled',
value='true' if enabled else 'false',
description='Ob Vertretungsplan aktiviert ist'
)
session.add(enabled_setting)
session.commit()
return jsonify({
'url': url,
'enabled': enabled,
'message': 'Supplement table settings updated successfully'
}), 200
except SQLAlchemyError as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()

View File

@@ -8,6 +8,8 @@ from server.routes.holidays import holidays_bp
from server.routes.academic_periods import academic_periods_bp from server.routes.academic_periods import academic_periods_bp
from server.routes.groups import groups_bp from server.routes.groups import groups_bp
from server.routes.clients import clients_bp from server.routes.clients import clients_bp
from server.routes.auth import auth_bp
from server.routes.system_settings import system_settings_bp
from server.database import Session, engine from server.database import Session, engine
from flask import Flask, jsonify, send_from_directory, request from flask import Flask, jsonify, send_from_directory, request
import glob import glob
@@ -17,8 +19,26 @@ sys.path.append('/workspace')
app = Flask(__name__) 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'
app.register_blueprint(system_settings_bp)
# 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 # Blueprints importieren und registrieren
app.register_blueprint(auth_bp)
app.register_blueprint(clients_bp) app.register_blueprint(clients_bp)
app.register_blueprint(groups_bp) app.register_blueprint(groups_bp)
app.register_blueprint(events_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.
---