feat: document user management system and RBAC implementation

- Update copilot-instructions.md with user model, API routes, and frontend patterns
- Update README.md with RBAC details, user management API, and security sections
- Add user management technical documentation to TECH-CHANGELOG.md
- Bump version to 2025.1.0-alpha.13 with user management changelog entries
This commit is contained in:
RobbStarkAustria
2025-12-29 12:37:54 +00:00
parent c193209326
commit 5a0c1bc686
13 changed files with 1823 additions and 28 deletions

View File

@@ -104,6 +104,7 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- 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`. 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. The scheduler publishes only events that are active at the current time and clears retained topics (publishes `[]`) for groups without active events. Time comparisons are UTC and naive timestamps are normalized. - 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. The scheduler publishes only events that are active at the current time and clears retained topics (publishes `[]`) for groups without active events. Time comparisons are UTC and naive timestamps are normalized.
- Listener also creates its own engine for writes to `clients`. - Listener also creates its own engine for writes to `clients`.
- Scheduler queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules, applies event exceptions (skipped dates, detached occurrences), and publishes only events that are active at the current time (UTC). When a group has no active events, the scheduler clears its retained topic by publishing an empty list. Time comparisons are UTC; naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media.
- 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`.
- Heartbeat: `infoscreen/{uuid}/heartbeat` updates `Client.last_alive` (UTC). - Heartbeat: `infoscreen/{uuid}/heartbeat` updates `Client.last_alive` (UTC).
@@ -122,6 +123,12 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- Storage: originals under `server/media/…`, outputs under `server/media/converted/` (prod compose mounts a shared volume for this path). - Storage: originals under `server/media/…`, outputs under `server/media/converted/` (prod compose mounts a shared volume for this path).
## Data model highlights (see `models/models.py`) ## Data model highlights (see `models/models.py`)
- User model: Includes 7 new audit/security fields (migration: `4f0b8a3e5c20_add_user_audit_fields.py`):
- `last_login_at`, `last_password_change_at`: TIMESTAMP (UTC) tracking for auth events
- `failed_login_attempts`, `last_failed_login_at`: Security monitoring for brute-force detection
- `locked_until`: TIMESTAMP placeholder for account lockout (infrastructure in place, not yet enforced)
- `deactivated_at`, `deactivated_by`: Soft-delete audit trail (FK self-reference); soft deactivation is the default, hard delete superadmin-only
- Role hierarchy (privilege escalation enforced): `user` < `editor` < `admin` < `superadmin`
- 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`. - 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`.
- Presentation defaults (system-wide): - Presentation defaults (system-wide):
- `presentation_interval` (seconds, default "10") - `presentation_interval` (seconds, default "10")
@@ -157,6 +164,14 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- `GET /api/academic_periods/active` — currently active period - `GET /api/academic_periods/active` — currently active period
- `POST /api/academic_periods/active` — set active period (deactivates others) - `POST /api/academic_periods/active` — set active period (deactivates others)
- `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date - `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date
- User management: `server/routes/users.py` exposes comprehensive CRUD for users (admin+):
- `GET /api/users` — list all users (role-filtered: admin sees user/editor/admin, superadmin sees all); includes audit fields in camelCase (lastLoginAt, lastPasswordChangeAt, failedLoginAttempts, deactivatedAt, deactivatedBy)
- `POST /api/users` — create user with username, password (min 6 chars), role, and status; admin cannot create superadmin; initializes audit fields
- `GET /api/users/<id>` — get detailed user record with all audit fields
- `PUT /api/users/<id>` — update user (cannot change own role/status; admin cannot modify superadmin accounts)
- `PUT /api/users/<id>/password` — admin password reset (requires backend check to reject self-reset for consistency)
- `DELETE /api/users/<id>` — hard delete (superadmin only, with self-deletion check)
- Auth routes (`server/routes/auth.py`): Enhanced to track login events (sets `last_login_at`, resets `failed_login_attempts` on success; increments `failed_login_attempts` and `last_failed_login_at` on failure). Self-service password change via `PUT /api/auth/change-password` requires current password verification.
Documentation maintenance: keep this file aligned with real patterns; update when routes/session/UTC rules change. Avoid long prose; link exact paths. Documentation maintenance: keep this file aligned with real patterns; update when routes/session/UTC rules change. Avoid long prose; link exact paths.
@@ -195,9 +210,24 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- 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): - Header user menu (top-right):
- Shows current username and role; click opens a menu with Profil and Abmelden. - Shows current username and role; click opens a menu with "Passwort ändern" (lock icon), "Profil", and "Abmelden".
- Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`). - Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`).
- “Abmelden” navigates to `/logout`; the page invokes backend logout and redirects to `/login`. - "Passwort ändern": Opens self-service password change dialog (available to all authenticated users); requires current password verification, new password min 6 chars, must match confirm field; calls `PUT /api/auth/change-password`
- "Abmelden" navigates to `/logout`; the page invokes backend logout and redirects to `/login`.
- User management page (`dashboard/src/users.tsx`):
- Full CRUD interface for managing users (admin+ only in menu); accessible via "Benutzer" sidebar entry
- Syncfusion GridComponent: 20 per page (configurable), sortable columns (ID, username, role), custom action button template with role-based visibility
- Statistics cards: total users, active (non-deactivated), inactive (deactivated) counts
- Dialogs: Create (username/password/role/status), Edit (with self-edit protections), Password Reset (admin only, no current password required), Delete (superadmin only, self-check), Details (read-only audit info with formatted timestamps)
- Role badges: Color-coded display (user: gray, editor: blue, admin: green, superadmin: red)
- Audit information displayed: last login, password change, last failed login, deactivation timestamps and deactivating user
- Role-based permissions (enforced backend + frontend):
- Admin: can manage user/editor/admin roles (not superadmin); soft-deactivate only; cannot see/edit superadmin accounts
- Superadmin: can manage all roles including other superadmins; can permanently hard-delete users
- Security rules enforced: cannot change own role, cannot deactivate own account, cannot delete self, cannot reset own password via admin route (must use self-service)
- API client in `dashboard/src/apiUsers.ts` for all user operations (listUsers, getUser, createUser, updateUser, resetUserPassword, deleteUser)
- Menu visibility: "Benutzer" menu item only visible to admin+ (role-gated in App.tsx)
- Settings page (`dashboard/src/settings.tsx`): - Settings page (`dashboard/src/settings.tsx`):
- Structure: Syncfusion TabComponent with role-gated tabs - Structure: Syncfusion TabComponent with role-gated tabs

View File

@@ -46,7 +46,11 @@ Data flow summary:
## 🌟 Key Features ## 🌟 Key Features
- **User Management**: Comprehensive role-based access control (user → editor → admin → superadmin)
- Admin panel for user CRUD operations with audit tracking
- Self-service password change available to all users
- Audit trail: login times, password changes, deactivation history
- Soft-delete by default, hard-delete superadmin-only
- 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
@@ -359,6 +363,19 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- `video_volume` (0.01.0, default "0.8") - `video_volume` (0.01.0, default "0.8")
- `video_muted` ("true"/"false", default "false") - `video_muted` ("true"/"false", default "false")
### User Management (Admin+)
- `GET /api/users` - List all users (role-filtered by user's role)
- `POST /api/users` - Create new user with username, password (min 6 chars), role, and status
- `GET /api/users/<id>` - Get user details including audit information (login times, password changes, deactivation)
- `PUT /api/users/<id>` - Update user (cannot change own role or account status)
- `PUT /api/users/<id>/password` - Admin password reset (cannot reset own password this way; use `/api/auth/change-password` instead)
- `DELETE /api/users/<id>` - Delete user permanently (superadmin only; cannot delete self)
### Authentication
- `POST /api/auth/login` - User login (tracks last login time and failed attempts)
- `POST /api/auth/logout` - User logout
- `PUT /api/auth/change-password` - Self-service password change (all authenticated users; requires current password verification)
### 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
@@ -385,7 +402,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. - **SplitButtons**: Header user menu (top-right) using Syncfusion DropDownButton to show current user and role, with actions "Passwort ändern", "Profil", and "Abmelden".
### Pages Overview ### Pages Overview
- **Dashboard**: Card-based overview of all Raumgruppen (room groups) with real-time status monitoring. Features include: - **Dashboard**: Card-based overview of all Raumgruppen (room groups) with real-time status monitoring. Features include:
@@ -400,6 +417,14 @@ 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
- **Users**: Comprehensive user management (admin+ only in menu)
- Full CRUD interface with sortable GridComponent (20 per page)
- Statistics cards: total, active, inactive user counts
- Create, edit, delete, and password reset dialogs
- User details modal showing audit information (login times, password changes, deactivation)
- Role badges with color coding (user: gray, editor: blue, admin: green, superadmin: red)
- Self-protection: cannot modify own account (cannot change role/status or delete self)
- Superadmin-only hard delete; other users soft-deactivate
- **Settings**: Central configuration (tabbed) - **Settings**: Central configuration (tabbed)
- 📅 Academic Calendar (all users): - 📅 Academic Calendar (all users):
- 📥 Import & Liste: CSV/TXT import combined with holidays list - 📥 Import & Liste: CSV/TXT import combined with holidays list
@@ -413,6 +438,18 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
## 🔒 Security & Authentication ## 🔒 Security & Authentication
- **Role-Based Access Control (RBAC)**: 4-tier hierarchy (user → editor → admin → superadmin) with privilege escalation protection
- Admin cannot see, manage, or create superadmin accounts
- Admin can create and manage user/editor/admin roles only
- Superadmin can manage all roles including other superadmins
- Role-gated menu visibility: users only see menu items they have permission for
- **Account Management**:
- Soft-delete by default (deactivated_at, deactivated_by timestamps)
- Hard-delete superadmin-only (permanent removal from database)
- Self-account protections: cannot change own role/status, cannot delete self via admin panel
- Self-service password change available to all authenticated users (requires current password verification)
- Admin password reset available for other users (no current password required)
- **Audit Tracking**: All user accounts track login times, password changes, failed login attempts, and deactivation history
- **Environment Variables**: Sensitive data via `.env` - **Environment Variables**: Sensitive data via `.env`
- **SSL/TLS**: HTTPS support with custom certificates - **SSL/TLS**: HTTPS support with custom certificates
- **MQTT Security**: Username/password authentication - **MQTT Security**: Username/password authentication
@@ -423,10 +460,11 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
## 📊 Monitoring & Logging ## 📊 Monitoring & Logging
### Health Checks ### Health Checks
**Scheduler**: Logging is concise; conversion lookups are cached and logged only once per media.
- 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
- **Scheduler**: Logging is concise; conversion lookups are cached and logged only once per media.
- Dashboard: Nginx availability
### Logging Strategy ### Logging Strategy
- **Development**: Docker Compose logs with service prefixes - **Development**: Docker Compose logs with service prefixes

View File

@@ -5,6 +5,57 @@
This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`. This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`.
## 2025.1.0-beta.1 (TBD)
- 🔐 **User Management & Role-Based Access Control**:
- Backend: Implemented comprehensive user management API (`server/routes/users.py`) with 6 endpoints (GET, POST, PUT, DELETE users + password reset).
- Data model: Extended `User` with 7 audit/security fields via Alembic migration (`4f0b8a3e5c20_add_user_audit_fields.py`):
- `last_login_at`, `last_password_change_at`: TIMESTAMP (UTC) for auth event tracking
- `failed_login_attempts`, `last_failed_login_at`: Security monitoring for brute-force detection
- `locked_until`: TIMESTAMP placeholder for account lockout (infrastructure in place, not yet enforced)
- `deactivated_at`, `deactivated_by`: Soft-delete audit trail (FK self-reference)
- Role hierarchy: 4-tier privilege escalation (user → editor → admin → superadmin) enforced at API and UI levels:
- Admin cannot see, create, or manage superadmin accounts
- Admin can manage user/editor/admin roles only
- Superadmin can manage all roles including other superadmins
- Auth routes enhanced (`server/routes/auth.py`):
- Login: Sets `last_login_at`, resets `failed_login_attempts` on success; increments `failed_login_attempts` and `last_failed_login_at` on failure
- Password change: Sets `last_password_change_at` on both self-service and admin reset
- New endpoint: `PUT /api/auth/change-password` for self-service password change (all authenticated users; requires current password verification)
- User API security:
- Admin cannot reset superadmin passwords
- Self-account protections: cannot change own role/status, cannot delete self
- Admin cannot use password reset endpoint for their own account (backend check enforces self-service requirement)
- All user responses include audit fields in camelCase (lastLoginAt, lastPasswordChangeAt, failedLoginAttempts, deactivatedAt, deactivatedBy)
- Soft-delete pattern: Deactivation by default (sets `deactivated_at` and `deactivated_by`); hard-delete superadmin-only
- 🖥️ **Frontend User Management**:
- New page: `dashboard/src/users.tsx` Full CRUD interface (820 lines) with Syncfusion components
- GridComponent: 20 per page (configurable), sortable columns (ID, username, role), custom action button template with role-based visibility
- Statistics cards: Total users, active (non-deactivated), inactive (deactivated) counts
- Dialogs: Create (username/password/role/status), Edit (with self-edit protections), Password Reset (admin only, no current password required), Delete (superadmin only, self-check), Details (read-only audit info with formatted timestamps)
- Role badges: Color-coded display (user: gray, editor: blue, admin: green, superadmin: red)
- Audit information display: last login, password change, last failed login, deactivation timestamps and deactivating user
- Self-protection: Delete button hidden for current user (prevents accidental self-deletion)
- Menu visibility: "Benutzer" sidebar item only visible to admin+ (role-gated in App.tsx)
- 💬 **Header User Menu**:
- Enhanced top-right dropdown with "Passwort ändern" (lock icon), "Profil", and "Abmelden"
- Self-service password change dialog: Available to all authenticated users; requires current password verification, new password min 6 chars, must match confirm field
- Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`)
- 🔌 **API Client**:
- New file: `dashboard/src/apiUsers.ts` Type-safe TypeScript client (143 lines) for user operations
- Functions: listUsers(), getUser(), createUser(), updateUser(), resetUserPassword(), deleteUser()
- All functions include proper error handling and camelCase JSON mapping
- 📖 **Documentation**:
- Updated `.github/copilot-instructions.md`: Added comprehensive sections on user model audit fields, user management API routes, auth routes, header menu, and user management page implementation
- Updated `README.md`: Added user management to Key Features, API endpoints (User Management + Authentication sections), Pages Overview, and Security & Authentication sections with RBAC details
- Updated `TECH-CHANGELOG.md`: Documented all technical changes and integration notes
Notes for integrators:
- User CRUD endpoints accept/return all audit fields in camelCase
- Admin password reset (`PUT /api/users/<id>/password`) cannot be used for admin's own account; users must use self-service endpoint
- Frontend enforces role-gated menu visibility; backend validates all role transitions to prevent privilege escalation
- Soft-delete is default; hard-delete (superadmin-only) requires explicit confirmation
- Audit fields populated automatically on login/logout/password-change/deactivation events
Backend rework (post-release notes; no version bump): Backend rework (post-release notes; no version bump):

View File

@@ -1,6 +1,6 @@
{ {
"appName": "Infoscreen-Management", "appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.12", "version": "2025.1.0-alpha.13",
"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.",
@@ -26,10 +26,28 @@
] ]
}, },
"buildInfo": { "buildInfo": {
"buildDate": "2025-11-27T12:00:00Z", "buildDate": "2025-12-29T12:00:00Z",
"commitId": "9f2ae8b44c3a" "commitId": "9f2ae8b44c3a"
}, },
"changelog": [ "changelog": [
{
"version": "2025.1.0-alpha.13",
"date": "2025-12-29",
"changes": [
"👥 UI: Neue 'Benutzer'-Seite mit vollständiger Benutzerverwaltung (CRUD) für Admins und Superadmins.",
"🔐 Benutzer-Seite: Sortierbare Gitter-Tabelle mit Benutzer-ID, Benutzername und Rolle; 20 Einträge pro Seite.",
"📊 Benutzer-Seite: Statistik-Karten zeigen Gesamtanzahl, aktive und inaktive Benutzer.",
" Benutzer-Seite: Dialog zum Erstellen neuer Benutzer (Benutzername, Passwort, Rolle, Status).",
"✏️ Benutzer-Seite: Dialog zum Bearbeiten von Benutzer-Details mit Schutz vor Selbst-Änderungen.",
"🔑 Benutzer-Seite: Dialog zum Zurücksetzen von Passwörtern durch Admins (ohne alte Passwort-Anfrage).",
"❌ Benutzer-Seite: Dialog zum Löschen von Benutzern (nur für Superadmins; verhindert Selbst-Löschung).",
"📋 Benutzer-Seite: Details-Modal zeigt Audit-Informationen (letzte Anmeldung, Passwort-Änderung, Abmeldungen).",
"🎨 Benutzer-Seite: Rollen-Abzeichen mit Farb-Kodierung (Benutzer: grau, Editor: blau, Admin: grün, Superadmin: rot).",
"🔒 Header-Menü: Neue 'Passwort ändern'-Option im Benutzer-Dropdown für Selbstbedienung (alle Benutzer).",
"🔐 Passwort-Dialog: Authentifizierung mit aktuellem Passwort erforderlich (min. 6 Zeichen für neues Passwort).",
"🎯 Rollenbasiert: Menu-Einträge werden basierend auf Benutzer-Rolle gefiltert (z.B. 'Benutzer' nur für Admin+)."
]
},
{ {
"version": "2025.1.0-alpha.12", "version": "2025.1.0-alpha.12",
"date": "2025-11-27", "date": "2025-11-27",

View File

@@ -4,7 +4,8 @@ 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 { DropDownButtonComponent } from '@syncfusion/ej2-react-splitbuttons';
import type { MenuEventArgs } from '@syncfusion/ej2-splitbuttons'; import type { MenuEventArgs } from '@syncfusion/ej2-splitbuttons';
import { TooltipComponent } from '@syncfusion/ej2-react-popups'; import { TooltipComponent, DialogComponent } from '@syncfusion/ej2-react-popups';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import logo from './assets/logo.png'; import logo from './assets/logo.png';
import './App.css'; import './App.css';
@@ -25,16 +26,16 @@ import {
import { ToastProvider } from './components/ToastProvider'; import { ToastProvider } from './components/ToastProvider';
const sidebarItems = [ const sidebarItems = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard }, { name: 'Dashboard', path: '/', icon: LayoutDashboard, minRole: 'user' },
{ name: 'Termine', path: '/termine', icon: Calendar }, { name: 'Termine', path: '/termine', icon: Calendar, minRole: 'user' },
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes }, { name: 'Ressourcen', path: '/ressourcen', icon: Boxes, minRole: 'editor' },
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon }, { name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon, minRole: 'admin' },
{ name: 'Infoscreen-Clients', path: '/clients', icon: Monitor }, { name: 'Infoscreen-Clients', path: '/clients', icon: Monitor, minRole: 'admin' },
{ name: 'Erweiterungsmodus', path: '/setup', icon: Wrench }, { name: 'Erweiterungsmodus', path: '/setup', icon: Wrench, minRole: 'admin' },
{ name: 'Medien', path: '/medien', icon: Image }, { name: 'Medien', path: '/medien', icon: Image, minRole: 'editor' },
{ name: 'Benutzer', path: '/benutzer', icon: User }, { name: 'Benutzer', path: '/benutzer', icon: User, minRole: 'admin' },
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings }, { name: 'Einstellungen', path: '/einstellungen', icon: Settings, minRole: 'admin' },
{ name: 'Programminfo', path: '/programminfo', icon: Info }, { name: 'Programminfo', path: '/programminfo', icon: Info, minRole: 'user' },
]; ];
// Dummy Components (können in eigene Dateien ausgelagert werden) // Dummy Components (können in eigene Dateien ausgelagert werden)
@@ -51,6 +52,8 @@ import Programminfo from './programminfo';
import Logout from './logout'; import Logout from './logout';
import Login from './login'; import Login from './login';
import { useAuth } from './useAuth'; import { useAuth } from './useAuth';
import { changePassword } from './apiAuth';
import { useToast } from './components/ToastProvider';
// 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';
@@ -60,8 +63,16 @@ const Layout: React.FC = () => {
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
let sidebarRef: SidebarComponent | null; let sidebarRef: SidebarComponent | null;
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
// Change password dialog state
const [showPwdDialog, setShowPwdDialog] = useState(false);
const [pwdCurrent, setPwdCurrent] = useState('');
const [pwdNew, setPwdNew] = useState('');
const [pwdConfirm, setPwdConfirm] = useState('');
const [pwdBusy, setPwdBusy] = useState(false);
React.useEffect(() => { React.useEffect(() => {
fetch('/program-info.json') fetch('/program-info.json')
.then(res => res.json()) .then(res => res.json())
@@ -87,6 +98,33 @@ const Layout: React.FC = () => {
} }
}; };
const submitPasswordChange = async () => {
if (!pwdCurrent || !pwdNew || !pwdConfirm) {
toast.show({ content: 'Bitte alle Felder ausfüllen', cssClass: 'e-toast-warning' });
return;
}
if (pwdNew.length < 6) {
toast.show({ content: 'Neues Passwort muss mindestens 6 Zeichen haben', cssClass: 'e-toast-warning' });
return;
}
if (pwdNew !== pwdConfirm) {
toast.show({ content: 'Passwörter stimmen nicht überein', cssClass: 'e-toast-warning' });
return;
}
setPwdBusy(true);
try {
await changePassword(pwdCurrent, pwdNew);
toast.show({ content: 'Passwort erfolgreich geändert', cssClass: 'e-toast-success' });
setShowPwdDialog(false);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Ändern des Passworts';
toast.show({ content: msg, cssClass: 'e-toast-danger' });
} finally {
setPwdBusy(false);
}
};
const sidebarTemplate = () => ( const sidebarTemplate = () => (
<div <div
className={`sidebar-theme ${isCollapsed ? 'collapsed' : 'expanded'}`} className={`sidebar-theme ${isCollapsed ? 'collapsed' : 'expanded'}`}
@@ -132,7 +170,16 @@ const Layout: React.FC = () => {
minHeight: 0, // Wichtig für Flex-Shrinking minHeight: 0, // Wichtig für Flex-Shrinking
}} }}
> >
{sidebarItems.map(item => { {sidebarItems
.filter(item => {
// Only show items the current user is allowed to see
if (!user) return false;
const roleHierarchy = ['user', 'editor', 'admin', 'superadmin'];
const userRoleIndex = roleHierarchy.indexOf(user.role);
const itemRoleIndex = roleHierarchy.indexOf(item.minRole || 'user');
return userRoleIndex >= itemRoleIndex;
})
.map(item => {
const Icon = item.icon; const Icon = item.icon;
const linkContent = ( const linkContent = (
<Link <Link
@@ -305,13 +352,16 @@ const Layout: React.FC = () => {
{user && ( {user && (
<DropDownButtonComponent <DropDownButtonComponent
items={[ items={[
{ text: 'Profil', id: 'profile', iconCss: 'e-icons e-user' }, { text: 'Passwort ändern', id: 'change-password', iconCss: 'e-icons e-lock' },
{ separator: true }, { separator: true },
{ text: 'Abmelden', id: 'logout', iconCss: 'e-icons e-logout' }, { text: 'Abmelden', id: 'logout', iconCss: 'e-icons e-logout' },
]} ]}
select={(args: MenuEventArgs) => { select={(args: MenuEventArgs) => {
if (args.item.id === 'profile') { if (args.item.id === 'change-password') {
navigate('/benutzer'); setPwdCurrent('');
setPwdNew('');
setPwdConfirm('');
setShowPwdDialog(true);
} else if (args.item.id === 'logout') { } else if (args.item.id === 'logout') {
navigate('/logout'); navigate('/logout');
} }
@@ -339,6 +389,57 @@ const Layout: React.FC = () => {
)} )}
</div> </div>
</header> </header>
<DialogComponent
isModal={true}
visible={showPwdDialog}
width="480px"
header="Passwort ändern"
showCloseIcon={true}
close={() => setShowPwdDialog(false)}
footerTemplate={() => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<ButtonComponent cssClass="e-flat" onClick={() => setShowPwdDialog(false)} disabled={pwdBusy}>
Abbrechen
</ButtonComponent>
<ButtonComponent cssClass="e-primary" onClick={submitPasswordChange} disabled={pwdBusy}>
{pwdBusy ? 'Speichere...' : 'Speichern'}
</ButtonComponent>
</div>
)}
>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Aktuelles Passwort *</label>
<TextBoxComponent
type="password"
placeholder="Aktuelles Passwort"
value={pwdCurrent}
input={(e: any) => setPwdCurrent(e.value)}
disabled={pwdBusy}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Neues Passwort *</label>
<TextBoxComponent
type="password"
placeholder="Mindestens 6 Zeichen"
value={pwdNew}
input={(e: any) => setPwdNew(e.value)}
disabled={pwdBusy}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Neues Passwort bestätigen *</label>
<TextBoxComponent
type="password"
placeholder="Wiederholen"
value={pwdConfirm}
input={(e: any) => setPwdConfirm(e.value)}
disabled={pwdBusy}
/>
</div>
</div>
</DialogComponent>
<main className="page-content"> <main className="page-content">
<Outlet /> <Outlet />
</main> </main>

View File

@@ -31,6 +31,26 @@ export interface AuthCheckResponse {
role?: string; role?: string;
} }
/**
* Change password for the currently authenticated user.
*/
export async function changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
const res = await fetch('/api/auth/change-password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to change password');
}
return data as { message: string };
}
/** /**
* Authenticate a user with username and password. * Authenticate a user with username and password.
* *

161
dashboard/src/apiUsers.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* User management API client.
*
* Provides functions to manage users (CRUD operations).
* Access is role-based: admin can manage user/editor/admin, superadmin can manage all.
*/
export interface UserData {
id: number;
username: string;
role: 'user' | 'editor' | 'admin' | 'superadmin';
isActive: boolean;
lastLoginAt?: string;
lastPasswordChangeAt?: string;
lastFailedLoginAt?: string;
failedLoginAttempts?: number;
lockedUntil?: string;
deactivatedAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateUserRequest {
username: string;
password: string;
role: 'user' | 'editor' | 'admin' | 'superadmin';
isActive?: boolean;
}
export interface UpdateUserRequest {
username?: string;
role?: 'user' | 'editor' | 'admin' | 'superadmin';
isActive?: boolean;
}
export interface ResetPasswordRequest {
password: string;
}
/**
* List all users (filtered by current user's role).
* Admin sees: user, editor, admin
* Superadmin sees: all including superadmin
*/
export async function listUsers(): Promise<UserData[]> {
const res = await fetch('/api/users', {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to fetch users');
}
return res.json();
}
/**
* Get a single user by ID.
*/
export async function getUser(userId: number): Promise<UserData> {
const res = await fetch(`/api/users/${userId}`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to fetch user');
}
return res.json();
}
/**
* Create a new user.
* Admin: can create user, editor, admin
* Superadmin: can create any role including superadmin
*/
export async function createUser(userData: CreateUserRequest): Promise<UserData & { message: string }> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(userData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to create user');
}
return data;
}
/**
* Update a user's details.
* Restrictions:
* - Cannot change own role
* - Cannot change own active status
* - Admin cannot edit superadmin users
*/
export async function updateUser(userId: number, userData: UpdateUserRequest): Promise<UserData & { message: string }> {
const res = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(userData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to update user');
}
return data;
}
/**
* Reset a user's password.
* Admin: cannot reset superadmin passwords
* Superadmin: can reset any password
*/
export async function resetUserPassword(userId: number, password: string): Promise<{ message: string }> {
const res = await fetch(`/api/users/${userId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to reset password');
}
return data;
}
/**
* Permanently delete a user (superadmin only).
* Cannot delete own account.
*/
export async function deleteUser(userId: number): Promise<{ message: string }> {
const res = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to delete user');
}
return data;
}

View File

@@ -1,8 +1,822 @@
import React from 'react'; import React from 'react';
const Benutzer: React.FC = () => ( import { useAuth } from './useAuth';
<div> import {
<h2 className="text-xl font-bold mb-4">Benutzer</h2> GridComponent,
<p>Willkommen im Infoscreen-Management Benutzer.</p> ColumnsDirective,
ColumnDirective,
Page,
Inject,
Toolbar,
Edit,
CommandColumn,
type EditSettingsModel,
type CommandModel,
} from '@syncfusion/ej2-react-grids';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import {
listUsers,
createUser,
updateUser,
resetUserPassword,
deleteUser,
type UserData,
} from './apiUsers';
const Benutzer: React.FC = () => {
const { user: currentUser } = useAuth();
const [users, setUsers] = React.useState<UserData[]>([]);
const [loading, setLoading] = React.useState(true);
// Dialog states
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
const [showEditDialog, setShowEditDialog] = React.useState(false);
const [showPasswordDialog, setShowPasswordDialog] = React.useState(false);
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
const [showDetailsDialog, setShowDetailsDialog] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState<UserData | null>(null);
// Form states
const [formUsername, setFormUsername] = React.useState('');
const [formPassword, setFormPassword] = React.useState('');
const [formRole, setFormRole] = React.useState<'user' | 'editor' | 'admin' | 'superadmin'>('user');
const [formIsActive, setFormIsActive] = React.useState(true);
const [formBusy, setFormBusy] = React.useState(false);
const toastRef = React.useRef<ToastComponent>(null);
const isSuperadmin = currentUser?.role === 'superadmin';
// Available roles based on current user's role
const availableRoles = React.useMemo(() => {
if (isSuperadmin) {
return [
{ value: 'user', text: 'Benutzer (Viewer)' },
{ value: 'editor', text: 'Editor (Content Manager)' },
{ value: 'admin', text: 'Administrator' },
{ value: 'superadmin', text: 'Superadministrator' },
];
}
return [
{ value: 'user', text: 'Benutzer (Viewer)' },
{ value: 'editor', text: 'Editor (Content Manager)' },
{ value: 'admin', text: 'Administrator' },
];
}, [isSuperadmin]);
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
if (toastRef.current) {
toastRef.current.show({
content,
cssClass,
timeOut: 4000,
});
}
};
const loadUsers = React.useCallback(async () => {
try {
setLoading(true);
const data = await listUsers();
setUsers(data);
} catch (error) {
const message = error instanceof Error ? error.message : 'Fehler beim Laden der Benutzer';
showToast(message, 'e-toast-danger');
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
loadUsers();
}, [loadUsers]);
// Create user
const handleCreateClick = () => {
setFormUsername('');
setFormPassword('');
setFormRole('user');
setFormIsActive(true);
setShowCreateDialog(true);
};
const handleCreateSubmit = async () => {
if (!formUsername.trim()) {
showToast('Benutzername ist erforderlich', 'e-toast-warning');
return;
}
if (formUsername.trim().length < 3) {
showToast('Benutzername muss mindestens 3 Zeichen lang sein', 'e-toast-warning');
return;
}
if (!formPassword) {
showToast('Passwort ist erforderlich', 'e-toast-warning');
return;
}
if (formPassword.length < 6) {
showToast('Passwort muss mindestens 6 Zeichen lang sein', 'e-toast-warning');
return;
}
setFormBusy(true);
try {
await createUser({
username: formUsername.trim(),
password: formPassword,
role: formRole,
isActive: formIsActive,
});
showToast('Benutzer erfolgreich erstellt', 'e-toast-success');
setShowCreateDialog(false);
loadUsers();
} catch (error) {
const message = error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers';
showToast(message, 'e-toast-danger');
} finally {
setFormBusy(false);
}
};
// Edit user
const handleEditClick = (userData: UserData) => {
setSelectedUser(userData);
setFormUsername(userData.username);
setFormRole(userData.role);
setFormIsActive(userData.isActive);
setShowEditDialog(true);
};
const handleEditSubmit = async () => {
if (!selectedUser) return;
if (!formUsername.trim()) {
showToast('Benutzername ist erforderlich', 'e-toast-warning');
return;
}
if (formUsername.trim().length < 3) {
showToast('Benutzername muss mindestens 3 Zeichen lang sein', 'e-toast-warning');
return;
}
setFormBusy(true);
try {
await updateUser(selectedUser.id, {
username: formUsername.trim(),
role: formRole,
isActive: formIsActive,
});
showToast('Benutzer erfolgreich aktualisiert', 'e-toast-success');
setShowEditDialog(false);
loadUsers();
} catch (error) {
const message = error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers';
showToast(message, 'e-toast-danger');
} finally {
setFormBusy(false);
}
};
// Reset password
const handlePasswordClick = (userData: UserData) => {
if (currentUser && userData.id === currentUser.id) {
showToast('Bitte ändern Sie Ihr eigenes Passwort über das Benutzer-Menü (oben rechts).', 'e-toast-warning');
return;
}
setSelectedUser(userData);
setFormPassword('');
setShowPasswordDialog(true);
};
const handlePasswordSubmit = async () => {
if (!selectedUser) return;
if (!formPassword) {
showToast('Passwort ist erforderlich', 'e-toast-warning');
return;
}
if (formPassword.length < 6) {
showToast('Passwort muss mindestens 6 Zeichen lang sein', 'e-toast-warning');
return;
}
setFormBusy(true);
try {
await resetUserPassword(selectedUser.id, formPassword);
showToast('Passwort erfolgreich zurückgesetzt', 'e-toast-success');
setShowPasswordDialog(false);
} catch (error) {
const message = error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts';
showToast(message, 'e-toast-danger');
} finally {
setFormBusy(false);
}
};
// Delete user
const handleDeleteClick = (userData: UserData) => {
setSelectedUser(userData);
setShowDeleteDialog(true);
};
const handleDeleteConfirm = async () => {
if (!selectedUser) return;
setFormBusy(true);
try {
await deleteUser(selectedUser.id);
showToast('Benutzer erfolgreich gelöscht', 'e-toast-success');
setShowDeleteDialog(false);
loadUsers();
} catch (error) {
const message = error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers';
showToast(message, 'e-toast-danger');
} finally {
setFormBusy(false);
}
};
// View details
const handleDetailsClick = (userData: UserData) => {
setSelectedUser(userData);
setShowDetailsDialog(true);
};
// Format date-time
const getRoleBadge = (role: string) => {
const roleMap: Record<string, { text: string; color: string }> = {
user: { text: 'Benutzer', color: '#6c757d' },
editor: { text: 'Editor', color: '#0d6efd' },
admin: { text: 'Admin', color: '#198754' },
superadmin: { text: 'Superadmin', color: '#dc3545' },
};
const info = roleMap[role] || { text: role, color: '#6c757d' };
return (
<span
style={{
padding: '4px 12px',
borderRadius: '12px',
backgroundColor: info.color,
color: 'white',
fontSize: '12px',
fontWeight: 500,
display: 'inline-block',
}}
>
{info.text}
</span>
);
};
// Status badge
const getStatusBadge = (isActive: boolean) => {
return (
<span
style={{
padding: '4px 12px',
borderRadius: '12px',
backgroundColor: isActive ? '#28a745' : '#dc3545',
color: 'white',
fontSize: '12px',
fontWeight: 500,
display: 'inline-block',
}}
>
{isActive ? 'Aktiv' : 'Inaktiv'}
</span>
);
};
// Grid commands - no longer needed with custom template
// const commands: CommandModel[] = [...]
// Command click handler removed - using custom button template instead
// Format dates
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '-';
}
};
if (loading) {
return (
<div style={{ padding: 24 }}>
<div style={{ textAlign: 'center', padding: 40 }}>Lade Benutzer...</div>
</div> </div>
); );
}
return (
<div style={{ padding: 24 }}>
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
{/* Header */}
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>Benutzerverwaltung</h2>
<p style={{ margin: '8px 0 0 0', color: '#6c757d' }}>
Verwalten Sie Benutzer und deren Rollen
</p>
</div>
<ButtonComponent
cssClass="e-success"
iconCss="e-icons e-plus"
onClick={handleCreateClick}
>
Neuer Benutzer
</ButtonComponent>
</div>
{/* Statistics */}
<div style={{ marginBottom: 24, display: 'flex', gap: 16 }}>
<div className="e-card" style={{ flex: 1, padding: 16 }}>
<div style={{ fontSize: 14, color: '#6c757d', marginBottom: 4 }}>Gesamt</div>
<div style={{ fontSize: 28, fontWeight: 600 }}>{users.length}</div>
</div>
<div className="e-card" style={{ flex: 1, padding: 16 }}>
<div style={{ fontSize: 14, color: '#6c757d', marginBottom: 4 }}>Aktiv</div>
<div style={{ fontSize: 28, fontWeight: 600, color: '#28a745' }}>
{users.filter(u => u.isActive).length}
</div>
</div>
<div className="e-card" style={{ flex: 1, padding: 16 }}>
<div style={{ fontSize: 14, color: '#6c757d', marginBottom: 4 }}>Inaktiv</div>
<div style={{ fontSize: 28, fontWeight: 600, color: '#dc3545' }}>
{users.filter(u => !u.isActive).length}
</div>
</div>
</div>
{/* Users Grid */}
<GridComponent
dataSource={users}
allowPaging={true}
allowSorting={true}
pageSettings={{ pageSize: 20, pageSizes: [10, 20, 50, 100] }}
height="600"
>
<ColumnsDirective>
<ColumnDirective field="id" headerText="ID" width="80" textAlign="Center" allowSorting={true} />
<ColumnDirective
field="username"
headerText="Benutzername"
width="200"
allowSorting={true}
/>
<ColumnDirective
field="role"
headerText="Rolle"
width="150"
allowSorting={true}
template={(props: UserData) => getRoleBadge(props.role)}
/>
<ColumnDirective
field="isActive"
headerText="Status"
width="120"
template={(props: UserData) => getStatusBadge(props.isActive)}
/>
<ColumnDirective
field="createdAt"
headerText="Erstellt"
width="180"
template={(props: UserData) => formatDate(props.createdAt)}
/>
<ColumnDirective
headerText="Aktionen"
width="280"
template={(props: UserData) => (
<div style={{ display: 'flex', gap: 4 }}>
<ButtonComponent
cssClass="e-flat"
onClick={() => handleDetailsClick(props)}
>
Details
</ButtonComponent>
<ButtonComponent
cssClass="e-flat e-primary"
onClick={() => handleEditClick(props)}
>
Bearbeiten
</ButtonComponent>
<ButtonComponent
cssClass="e-flat e-info"
onClick={() => handlePasswordClick(props)}
>
Passwort
</ButtonComponent>
{isSuperadmin && currentUser?.id !== props.id && (
<ButtonComponent
cssClass="e-flat e-danger"
onClick={() => handleDeleteClick(props)}
>
Löschen
</ButtonComponent>
)}
</div>
)}
/>
</ColumnsDirective>
<Inject services={[Page, Toolbar, Edit, CommandColumn]} />
</GridComponent>
{/* Create User Dialog */}
<DialogComponent
isModal={true}
visible={showCreateDialog}
width="500px"
header="Neuer Benutzer"
showCloseIcon={true}
close={() => setShowCreateDialog(false)}
footerTemplate={() => (
<div>
<ButtonComponent
cssClass="e-flat"
onClick={() => setShowCreateDialog(false)}
disabled={formBusy}
>
Abbrechen
</ButtonComponent>
<ButtonComponent
cssClass="e-primary"
onClick={handleCreateSubmit}
disabled={formBusy}
>
{formBusy ? 'Erstelle...' : 'Erstellen'}
</ButtonComponent>
</div>
)}
>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Benutzername *
</label>
<TextBoxComponent
placeholder="Benutzername eingeben"
value={formUsername}
input={(e: any) => setFormUsername(e.value)}
disabled={formBusy}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Passwort *
</label>
<TextBoxComponent
type="password"
placeholder="Mindestens 6 Zeichen"
value={formPassword}
input={(e: any) => setFormPassword(e.value)}
disabled={formBusy}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Rolle *
</label>
<DropDownListComponent
dataSource={availableRoles}
fields={{ value: 'value', text: 'text' }}
value={formRole}
change={(e: any) => setFormRole(e.value)}
disabled={formBusy}
/>
</div>
<div style={{ marginBottom: 8 }}>
<CheckBoxComponent
label="Benutzer ist aktiv"
checked={formIsActive}
change={(e: any) => setFormIsActive(e.checked)}
disabled={formBusy}
/>
</div>
</div>
</DialogComponent>
{/* Edit User Dialog */}
<DialogComponent
isModal={true}
visible={showEditDialog}
width="500px"
header={`Benutzer bearbeiten: ${selectedUser?.username}`}
showCloseIcon={true}
close={() => setShowEditDialog(false)}
footerTemplate={() => (
<div>
<ButtonComponent
cssClass="e-flat"
onClick={() => setShowEditDialog(false)}
disabled={formBusy}
>
Abbrechen
</ButtonComponent>
<ButtonComponent
cssClass="e-primary"
onClick={handleEditSubmit}
disabled={formBusy}
>
{formBusy ? 'Speichere...' : 'Speichern'}
</ButtonComponent>
</div>
)}
>
<div style={{ padding: 16 }}>
{selectedUser?.id === currentUser?.id && (
<div
style={{
padding: 12,
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: 4,
marginBottom: 16,
fontSize: 14,
}}
>
Sie bearbeiten Ihr eigenes Konto. Sie können Ihre eigene Rolle oder Ihren aktiven Status nicht ändern.
</div>
)}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Benutzername *
</label>
<TextBoxComponent
placeholder="Benutzername eingeben"
value={formUsername}
input={(e: any) => setFormUsername(e.value)}
disabled={formBusy}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Rolle *
</label>
<DropDownListComponent
dataSource={availableRoles}
fields={{ value: 'value', text: 'text' }}
value={formRole}
change={(e: any) => setFormRole(e.value)}
disabled={formBusy || selectedUser?.id === currentUser?.id}
/>
{selectedUser?.id === currentUser?.id && (
<div style={{ fontSize: 12, color: '#6c757d', marginTop: 4 }}>
Sie können Ihre eigene Rolle nicht ändern
</div>
)}
</div>
<div style={{ marginBottom: 8 }}>
<CheckBoxComponent
label="Benutzer ist aktiv"
checked={formIsActive}
change={(e: any) => setFormIsActive(e.checked)}
disabled={formBusy || selectedUser?.id === currentUser?.id}
/>
{selectedUser?.id === currentUser?.id && (
<div style={{ fontSize: 12, color: '#6c757d', marginTop: 4 }}>
Sie können Ihr eigenes Konto nicht deaktivieren
</div>
)}
</div>
</div>
</DialogComponent>
{/* Reset Password Dialog */}
<DialogComponent
isModal={true}
visible={showPasswordDialog}
width="500px"
header={`Passwort zurücksetzen: ${selectedUser?.username}`}
showCloseIcon={true}
close={() => setShowPasswordDialog(false)}
footerTemplate={() => (
<div>
<ButtonComponent
cssClass="e-flat"
onClick={() => setShowPasswordDialog(false)}
disabled={formBusy}
>
Abbrechen
</ButtonComponent>
<ButtonComponent
cssClass="e-warning"
onClick={handlePasswordSubmit}
disabled={formBusy}
>
{formBusy ? 'Setze zurück...' : 'Zurücksetzen'}
</ButtonComponent>
</div>
)}
>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Neues Passwort *
</label>
<TextBoxComponent
type="password"
placeholder="Mindestens 6 Zeichen"
value={formPassword}
input={(e: any) => setFormPassword(e.value)}
disabled={formBusy}
/>
</div>
<div
style={{
padding: 12,
backgroundColor: '#d1ecf1',
border: '1px solid #bee5eb',
borderRadius: 4,
fontSize: 14,
}}
>
💡 Das neue Passwort wird sofort wirksam. Informieren Sie den Benutzer über das neue Passwort.
</div>
</div>
</DialogComponent>
{/* Delete User Dialog */}
<DialogComponent
isModal={true}
visible={showDeleteDialog}
width="500px"
header="Benutzer löschen"
showCloseIcon={true}
close={() => setShowDeleteDialog(false)}
footerTemplate={() => (
<div>
<ButtonComponent
cssClass="e-flat"
onClick={() => setShowDeleteDialog(false)}
disabled={formBusy}
>
Abbrechen
</ButtonComponent>
<ButtonComponent
cssClass="e-danger"
onClick={handleDeleteConfirm}
disabled={formBusy}
>
{formBusy ? 'Lösche...' : 'Endgültig löschen'}
</ButtonComponent>
</div>
)}
>
<div style={{ padding: 16 }}>
<div
style={{
padding: 16,
backgroundColor: '#f8d7da',
border: '1px solid #f5c6cb',
borderRadius: 4,
marginBottom: 16,
}}
>
<strong> Warnung: Diese Aktion kann nicht rückgängig gemacht werden!</strong>
</div>
<p style={{ marginBottom: 16 }}>
Möchten Sie den Benutzer <strong>{selectedUser?.username}</strong> wirklich endgültig löschen?
</p>
<p style={{ margin: 0, fontSize: 14, color: '#6c757d' }}>
Tipp: Statt zu löschen, können Sie den Benutzer auch deaktivieren, um das Konto zu sperren und
gleichzeitig die Daten zu bewahren.
</p>
</div>
</DialogComponent>
{/* Details Dialog */}
<DialogComponent
isModal={true}
visible={showDetailsDialog}
width="600px"
header={`Details: ${selectedUser?.username}`}
showCloseIcon={true}
close={() => setShowDetailsDialog(false)}
footerTemplate={() => (
<div>
<ButtonComponent cssClass="e-flat" onClick={() => setShowDetailsDialog(false)}>
Schließen
</ButtonComponent>
</div>
)}
>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Account Info */}
<div>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14, fontWeight: 600, color: '#6c757d' }}>
Kontoinformation
</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: '#6c757d', marginBottom: 4 }}>Benutzer-ID</div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{selectedUser?.id}</div>
</div>
<div>
<div style={{ fontSize: 12, color: '#6c757d', marginBottom: 4 }}>Benutzername</div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{selectedUser?.username}</div>
</div>
<div>
<div style={{ fontSize: 12, color: '#6c757d', marginBottom: 4 }}>Rolle</div>
<div>{selectedUser ? getRoleBadge(selectedUser.role) : '-'}</div>
</div>
<div>
<div style={{ fontSize: 12, color: '#6c757d', marginBottom: 4 }}>Status</div>
<div>{selectedUser ? getStatusBadge(selectedUser.isActive) : '-'}</div>
</div>
</div>
</div>
{/* Security & Activity */}
<div>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14, fontWeight: 600, color: '#6c757d' }}>
Sicherheit & Aktivität
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#333' }}>Letzter Login:</div>
<div style={{ fontSize: 13, color: '#666' }}>
{selectedUser?.lastLoginAt ? formatDate(selectedUser.lastLoginAt) : 'Nie'}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#333' }}>Passwort geändert:</div>
<div style={{ fontSize: 13, color: '#666' }}>
{selectedUser?.lastPasswordChangeAt ? formatDate(selectedUser.lastPasswordChangeAt) : 'Nie'}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#333' }}>Fehlgeschlagene Logins:</div>
<div style={{ fontSize: 13, color: '#666' }}>
{selectedUser?.failedLoginAttempts || 0}
</div>
</div>
{selectedUser?.lastFailedLoginAt && (
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#333' }}>Letzter Fehler:</div>
<div style={{ fontSize: 13, color: '#666' }}>
{formatDate(selectedUser.lastFailedLoginAt)}
</div>
</div>
)}
</div>
</div>
{/* Deactivation Info (if applicable) */}
{selectedUser && !selectedUser.isActive && selectedUser.deactivatedAt && (
<div style={{ padding: 12, backgroundColor: '#fff3cd', border: '1px solid #ffc107', borderRadius: 4 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Konto deaktiviert</div>
<div style={{ fontSize: 12, color: '#856404' }}>
am {formatDate(selectedUser.deactivatedAt)}
</div>
</div>
)}
{/* Timestamps */}
<div>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14, fontWeight: 600, color: '#6c757d' }}>
Zeitleisten
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#333' }}>Erstellt:</div>
<div style={{ fontSize: 13, color: '#666' }}>
{selectedUser?.createdAt ? formatDate(selectedUser.createdAt) : '-'}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#333' }}>Zuletzt geändert:</div>
<div style={{ fontSize: 13, color: '#666' }}>
{selectedUser?.updatedAt ? formatDate(selectedUser.updatedAt) : '-'}
</div>
</div>
</div>
</div>
</div>
</DialogComponent>
</div>
);
};
export default Benutzer; export default Benutzer;

View File

@@ -28,6 +28,13 @@ class User(Base):
password_hash = Column(String(128), nullable=False) password_hash = Column(String(128), nullable=False)
role = Column(Enum(UserRole), nullable=False, default=UserRole.user) role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
last_login_at = Column(TIMESTAMP(timezone=True), nullable=True)
last_password_change_at = Column(TIMESTAMP(timezone=True), nullable=True)
last_failed_login_at = Column(TIMESTAMP(timezone=True), nullable=True)
failed_login_attempts = Column(Integer, nullable=False, default=0, server_default="0")
locked_until = Column(TIMESTAMP(timezone=True), nullable=True)
deactivated_at = Column(TIMESTAMP(timezone=True), nullable=True)
deactivated_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), created_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp()) server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp( updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(

View File

@@ -0,0 +1,52 @@
"""add user audit fields
Revision ID: 4f0b8a3e5c20
Revises: 21226a449037
Create Date: 2025-12-29 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4f0b8a3e5c20'
down_revision: Union[str, None] = '21226a449037'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('users', sa.Column('last_login_at', sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column('users', sa.Column('last_password_change_at', sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column('users', sa.Column('last_failed_login_at', sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column(
'users',
sa.Column('failed_login_attempts', sa.Integer(), nullable=False, server_default='0')
)
op.add_column('users', sa.Column('locked_until', sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column('users', sa.Column('deactivated_at', sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column('users', sa.Column('deactivated_by', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_users_deactivated_by_users',
'users',
'users',
['deactivated_by'],
['id'],
ondelete='SET NULL',
)
# Optional: keep server_default for failed_login_attempts; remove if you prefer no default after backfill
def downgrade() -> None:
"""Downgrade schema."""
op.drop_constraint('fk_users_deactivated_by_users', 'users', type_='foreignkey')
op.drop_column('users', 'deactivated_by')
op.drop_column('users', 'deactivated_at')
op.drop_column('users', 'locked_until')
op.drop_column('users', 'failed_login_attempts')
op.drop_column('users', 'last_failed_login_at')
op.drop_column('users', 'last_password_change_at')
op.drop_column('users', 'last_login_at')

View File

@@ -10,8 +10,10 @@ from flask import Blueprint, request, jsonify, session
import os import os
from server.database import Session from server.database import Session
from models.models import User, UserRole from models.models import User, UserRole
from server.permissions import require_auth
import bcrypt import bcrypt
import sys import sys
from datetime import datetime, timezone
sys.path.append('/workspace') sys.path.append('/workspace')
@@ -66,8 +68,17 @@ def login():
# Verify password # Verify password
if not bcrypt.checkpw(password.encode('utf-8'), user.password_hash.encode('utf-8')): if not bcrypt.checkpw(password.encode('utf-8'), user.password_hash.encode('utf-8')):
# Track failed login attempt
user.last_failed_login_at = datetime.now(timezone.utc)
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
db_session.commit()
return jsonify({"error": "Invalid credentials"}), 401 return jsonify({"error": "Invalid credentials"}), 401
# Successful login: update last_login_at and reset failed attempts
user.last_login_at = datetime.now(timezone.utc)
user.failed_login_attempts = 0
db_session.commit()
# Create session # Create session
session['user_id'] = user.id session['user_id'] = user.id
session['username'] = user.username session['username'] = user.username
@@ -173,6 +184,57 @@ def check_auth():
return jsonify({"authenticated": False}), 200 return jsonify({"authenticated": False}), 200
@auth_bp.route("/change-password", methods=["PUT"])
@require_auth
def change_password():
"""
Allow the authenticated user to change their own password.
Request body:
{
"current_password": "string",
"new_password": "string"
}
Returns:
200: {"message": "Password changed successfully"}
400: {"error": "Validation error"}
401: {"error": "Invalid current password"}
404: {"error": "User not found"}
"""
data = request.get_json() or {}
current_password = data.get("current_password", "")
new_password = data.get("new_password", "")
if not current_password or not new_password:
return jsonify({"error": "Current password and new password are required"}), 400
if len(new_password) < 6:
return jsonify({"error": "New password must be at least 6 characters"}), 400
user_id = session.get('user_id')
db_session = Session()
try:
user = db_session.query(User).filter_by(id=user_id).first()
if not user:
session.clear()
return jsonify({"error": "User not found"}), 404
# Verify current password
if not bcrypt.checkpw(current_password.encode('utf-8'), user.password_hash.encode('utf-8')):
return jsonify({"error": "Current password is incorrect"}), 401
# Update password hash and timestamp
new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
user.password_hash = new_hash
user.last_password_change_at = datetime.now(timezone.utc)
db_session.commit()
return jsonify({"message": "Password changed successfully"}), 200
finally:
db_session.close()
@auth_bp.route("/dev-login-superadmin", methods=["POST"]) @auth_bp.route("/dev-login-superadmin", methods=["POST"])
def dev_login_superadmin(): def dev_login_superadmin():
""" """

439
server/routes/users.py Normal file
View File

@@ -0,0 +1,439 @@
"""
User management routes.
This module provides endpoints for managing users (CRUD operations).
Access is role-based: admin can manage user/editor/admin, superadmin can manage all.
"""
from flask import Blueprint, request, jsonify, session
from server.database import Session
from models.models import User, UserRole
from server.permissions import require_role, superadmin_only
import bcrypt
import sys
from datetime import datetime, timezone
sys.path.append('/workspace')
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
@users_bp.route("", methods=["GET"])
@require_role('admin', 'superadmin')
def list_users():
"""
List all users (filtered by current user's role).
Admin: sees user, editor, admin
Superadmin: sees all including superadmin
Returns:
200: [
{
"id": int,
"username": "string",
"role": "string",
"isActive": boolean,
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
]
"""
db_session = Session()
try:
current_role = session.get('role')
query = db_session.query(User)
# Admin cannot see superadmin users
if current_role == 'admin':
query = query.filter(User.role.in_([UserRole.user, UserRole.editor, UserRole.admin]))
users = query.order_by(User.username).all()
result = []
for user in users:
result.append({
"id": user.id,
"username": user.username,
"role": user.role.value,
"isActive": user.is_active,
"lastLoginAt": user.last_login_at.isoformat() if user.last_login_at else None,
"lastPasswordChangeAt": user.last_password_change_at.isoformat() if user.last_password_change_at else None,
"failedLoginAttempts": user.failed_login_attempts,
"createdAt": user.created_at.isoformat() if user.created_at else None,
"updatedAt": user.updated_at.isoformat() if user.updated_at else None
})
return jsonify(result), 200
finally:
db_session.close()
@users_bp.route("", methods=["POST"])
@require_role('admin', 'superadmin')
def create_user():
"""
Create a new user.
Admin: can create user, editor, admin
Superadmin: can create any role including superadmin
Request body:
{
"username": "string",
"password": "string",
"role": "user|editor|admin|superadmin",
"isActive": boolean (optional, default true)
}
Returns:
201: {
"id": int,
"username": "string",
"role": "string",
"isActive": boolean,
"message": "User created successfully"
}
400: {"error": "Validation error"}
403: {"error": "Permission denied"}
409: {"error": "Username already exists"}
"""
data = request.get_json()
if not data:
return jsonify({"error": "Request body required"}), 400
username = data.get("username", "").strip()
password = data.get("password", "")
role_str = data.get("role", "user")
is_active = data.get("isActive", True)
# Validation
if not username:
return jsonify({"error": "Username is required"}), 400
if len(username) < 3:
return jsonify({"error": "Username must be at least 3 characters"}), 400
if not password:
return jsonify({"error": "Password is required"}), 400
if len(password) < 6:
return jsonify({"error": "Password must be at least 6 characters"}), 400
# Check if role is valid
try:
new_role = UserRole[role_str]
except KeyError:
return jsonify({"error": f"Invalid role: {role_str}"}), 400
# Check permissions: admin cannot create superadmin
current_role = session.get('role')
if current_role == 'admin' and new_role == UserRole.superadmin:
return jsonify({"error": "Admin cannot create superadmin accounts"}), 403
db_session = Session()
try:
# Check if username already exists
existing = db_session.query(User).filter_by(username=username).first()
if existing:
return jsonify({"error": "Username already exists"}), 409
# Hash password
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
# Create user
new_user = User(
username=username,
password_hash=password_hash,
role=new_role,
is_active=is_active
)
db_session.add(new_user)
db_session.commit()
return jsonify({
"id": new_user.id,
"username": new_user.username,
"role": new_user.role.value,
"isActive": new_user.is_active,
"message": "User created successfully"
}), 201
finally:
db_session.close()
@users_bp.route("/<int:user_id>", methods=["GET"])
@require_role('admin', 'superadmin')
def get_user(user_id):
"""
Get a single user by ID.
Admin: cannot get superadmin users
Superadmin: can get any user
Returns:
200: {
"id": int,
"username": "string",
"role": "string",
"isActive": boolean,
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
403: {"error": "Permission denied"}
404: {"error": "User not found"}
"""
db_session = Session()
try:
user = db_session.query(User).filter_by(id=user_id).first()
if not user:
return jsonify({"error": "User not found"}), 404
# Admin cannot view superadmin users
current_role = session.get('role')
if current_role == 'admin' and user.role == UserRole.superadmin:
return jsonify({"error": "Permission denied"}), 403
return jsonify({
"id": user.id,
"username": user.username,
"role": user.role.value,
"isActive": user.is_active,
"lastLoginAt": user.last_login_at.isoformat() if user.last_login_at else None,
"lastPasswordChangeAt": user.last_password_change_at.isoformat() if user.last_password_change_at else None,
"lastFailedLoginAt": user.last_failed_login_at.isoformat() if user.last_failed_login_at else None,
"failedLoginAttempts": user.failed_login_attempts,
"lockedUntil": user.locked_until.isoformat() if user.locked_until else None,
"deactivatedAt": user.deactivated_at.isoformat() if user.deactivated_at else None,
"createdAt": user.created_at.isoformat() if user.created_at else None,
"updatedAt": user.updated_at.isoformat() if user.updated_at else None
}), 200
finally:
db_session.close()
@users_bp.route("/<int:user_id>", methods=["PUT"])
@require_role('admin', 'superadmin')
def update_user(user_id):
"""
Update a user's details.
Admin: cannot edit superadmin users, cannot assign superadmin role
Superadmin: can edit any user
Restrictions:
- Cannot change own role
- Cannot change own active status
Request body:
{
"username": "string" (optional),
"role": "string" (optional),
"isActive": boolean (optional)
}
Returns:
200: {
"id": int,
"username": "string",
"role": "string",
"isActive": boolean,
"message": "User updated successfully"
}
400: {"error": "Validation error"}
403: {"error": "Permission denied"}
404: {"error": "User not found"}
409: {"error": "Username already exists"}
"""
data = request.get_json()
if not data:
return jsonify({"error": "Request body required"}), 400
current_user_id = session.get('user_id')
current_role = session.get('role')
db_session = Session()
try:
user = db_session.query(User).filter_by(id=user_id).first()
if not user:
return jsonify({"error": "User not found"}), 404
# Admin cannot edit superadmin users
if current_role == 'admin' and user.role == UserRole.superadmin:
return jsonify({"error": "Cannot edit superadmin users"}), 403
# Update username if provided
if "username" in data:
new_username = data["username"].strip()
if new_username and new_username != user.username:
if len(new_username) < 3:
return jsonify({"error": "Username must be at least 3 characters"}), 400
# Check if username already exists
existing = db_session.query(User).filter(
User.username == new_username,
User.id != user_id
).first()
if existing:
return jsonify({"error": "Username already exists"}), 409
user.username = new_username
# Update role if provided
if "role" in data:
role_str = data["role"]
# Cannot change own role
if user_id == current_user_id:
return jsonify({"error": "Cannot change your own role"}), 403
try:
new_role = UserRole[role_str]
except KeyError:
return jsonify({"error": f"Invalid role: {role_str}"}), 400
# Admin cannot assign superadmin role
if current_role == 'admin' and new_role == UserRole.superadmin:
return jsonify({"error": "Cannot assign superadmin role"}), 403
user.role = new_role
# Update active status if provided
if "isActive" in data:
# Cannot deactivate own account
if user_id == current_user_id:
return jsonify({"error": "Cannot deactivate your own account"}), 403
new_status = bool(data["isActive"])
user.is_active = new_status
# Track deactivation
if not new_status and not user.deactivated_at:
user.deactivated_at = datetime.now(timezone.utc)
user.deactivated_by = current_user_id
db_session.commit()
return jsonify({
"id": user.id,
"username": user.username,
"role": user.role.value,
"isActive": user.is_active, "lastLoginAt": None,
"lastPasswordChangeAt": None,
"failedLoginAttempts": 0, "lastLoginAt": user.last_login_at.isoformat() if user.last_login_at else None,
"lastPasswordChangeAt": user.last_password_change_at.isoformat() if user.last_password_change_at else None,
"failedLoginAttempts": user.failed_login_attempts,
"message": "User updated successfully"
}), 200
finally:
db_session.close()
@users_bp.route("/<int:user_id>/password", methods=["PUT"])
@require_role('admin', 'superadmin')
def reset_password(user_id):
"""
Reset a user's password.
Admin: cannot reset superadmin passwords
Superadmin: can reset any password
Request body:
{
"password": "string"
}
Returns:
200: {"message": "Password reset successfully"}
400: {"error": "Validation error"}
403: {"error": "Permission denied"}
404: {"error": "User not found"}
"""
data = request.get_json()
if not data:
return jsonify({"error": "Request body required"}), 400
password = data.get("password", "")
if not password:
return jsonify({"error": "Password is required"}), 400
if len(password) < 6:
return jsonify({"error": "Password must be at least 6 characters"}), 400
current_role = session.get('role')
current_user_id = session.get('user_id')
db_session = Session()
try:
user = db_session.query(User).filter_by(id=user_id).first()
if not user:
return jsonify({"error": "User not found"}), 404
# Users must change their own password via /auth/change-password (requires current password)
if user.id == current_user_id:
return jsonify({"error": "Use /api/auth/change-password to change your own password"}), 403
# Admin cannot reset superadmin passwords
if current_role == 'admin' and user.role == UserRole.superadmin:
return jsonify({"error": "Cannot reset superadmin passwords"}), 403
# Hash new password and update timestamp
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
user.password_hash = password_hash
user.last_password_change_at = datetime.now(timezone.utc)
db_session.commit()
return jsonify({"message": "Password reset successfully"}), 200
finally:
db_session.close()
@users_bp.route("/<int:user_id>", methods=["DELETE"])
@superadmin_only
def delete_user(user_id):
"""
Permanently delete a user (superadmin only).
Cannot delete own account.
Returns:
200: {"message": "User deleted successfully"}
403: {"error": "Cannot delete your own account"}
404: {"error": "User not found"}
"""
current_user_id = session.get('user_id')
# Cannot delete own account
if user_id == current_user_id:
return jsonify({"error": "Cannot delete your own account"}), 403
db_session = Session()
try:
user = db_session.query(User).filter_by(id=user_id).first()
if not user:
return jsonify({"error": "User not found"}), 404
username = user.username # Store for message
db_session.delete(user)
db_session.commit()
return jsonify({"message": f"User '{username}' deleted successfully"}), 200
finally:
db_session.close()

View File

@@ -9,6 +9,7 @@ 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.auth import auth_bp
from server.routes.users import users_bp
from server.routes.system_settings import system_settings_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
@@ -43,6 +44,7 @@ else:
# Blueprints importieren und registrieren # Blueprints importieren und registrieren
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(users_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)