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:
34
.github/copilot-instructions.md
vendored
34
.github/copilot-instructions.md
vendored
@@ -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` key–value 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` key–value 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
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -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.0–1.0, default "0.8")
|
- `video_volume` (0.0–1.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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
161
dashboard/src/apiUsers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
</div>
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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
439
server/routes/users.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user