diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c43087..372ae40 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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). - 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`. + - 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): - 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). @@ -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). ## 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`. - Presentation defaults (system-wide): - `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 - `POST /api/academic_periods/active` — set active period (deactivates others) - `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/` — get detailed user record with all audit fields + - `PUT /api/users/` — update user (cannot change own role/status; admin cannot modify superadmin accounts) + - `PUT /api/users//password` — admin password reset (requires backend check to reject self-reset for consistency) + - `DELETE /api/users/` — 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. @@ -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. - 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`). - - “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`): - Structure: Syncfusion TabComponent with role-gated tabs diff --git a/README.md b/README.md index 5fb31e0..b12a144 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,11 @@ Data flow summary: ## 🌟 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 - Real-time client monitoring and group management - 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_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/` - Get user details including audit information (login times, password changes, deactivation) +- `PUT /api/users/` - Update user (cannot change own role or account status) +- `PUT /api/users//password` - Admin password reset (cannot reset own password this way; use `/api/auth/change-password` instead) +- `DELETE /api/users/` - 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 - `GET /health` - Service health check - `GET /api/screenshots/{uuid}.jpg` - Client screenshots @@ -385,7 +402,7 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v - **Notifications**: Toast messages and alerts - **Pager**: Used on Programinfo changelog for pagination - **Cards (layouts)**: Programinfo sections styled with Syncfusion card classes - - **SplitButtons**: Header user menu (top-right) using Syncfusion DropDownButton to show current user and role, with actions “Profil” and “Abmelden”. + - **SplitButtons**: Header user menu (top-right) using Syncfusion DropDownButton to show current user and role, with actions "Passwort ändern", "Profil", and "Abmelden". ### Pages Overview - **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 - **Events**: Schedule management - **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) - 📅 Academic Calendar (all users): - 📥 Import & Liste: CSV/TXT import combined with holidays list @@ -413,6 +438,18 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v ## 🔒 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` - **SSL/TLS**: HTTPS support with custom certificates - **MQTT Security**: Username/password authentication @@ -423,10 +460,11 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v ## 📊 Monitoring & Logging ### Health Checks - **Scheduler**: Logging is concise; conversion lookups are cached and logged only once per media. - Database: Connection and initialization status - MQTT: Pub/sub functionality test - Dashboard: Nginx availability +- **Scheduler**: Logging is concise; conversion lookups are cached and logged only once per media. +- Dashboard: Nginx availability ### Logging Strategy - **Development**: Docker Compose logs with service prefixes diff --git a/TECH-CHANGELOG.md b/TECH-CHANGELOG.md index f0a230e..485400d 100644 --- a/TECH-CHANGELOG.md +++ b/TECH-CHANGELOG.md @@ -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`. +## 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//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): diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index 5f8106a..d54038c 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,6 +1,6 @@ { "appName": "Infoscreen-Management", - "version": "2025.1.0-alpha.12", + "version": "2025.1.0-alpha.13", "copyright": "© 2025 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", @@ -26,10 +26,28 @@ ] }, "buildInfo": { - "buildDate": "2025-11-27T12:00:00Z", + "buildDate": "2025-12-29T12:00:00Z", "commitId": "9f2ae8b44c3a" }, "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", "date": "2025-11-27", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index aed3976..b7c2334 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -4,7 +4,8 @@ import { SidebarComponent } from '@syncfusion/ej2-react-navigations'; import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; import { DropDownButtonComponent } from '@syncfusion/ej2-react-splitbuttons'; import type { MenuEventArgs } from '@syncfusion/ej2-splitbuttons'; -import { TooltipComponent } from '@syncfusion/ej2-react-popups'; +import { TooltipComponent, DialogComponent } from '@syncfusion/ej2-react-popups'; +import { TextBoxComponent } from '@syncfusion/ej2-react-inputs'; import logo from './assets/logo.png'; import './App.css'; @@ -25,16 +26,16 @@ import { import { ToastProvider } from './components/ToastProvider'; const sidebarItems = [ - { name: 'Dashboard', path: '/', icon: LayoutDashboard }, - { name: 'Termine', path: '/termine', icon: Calendar }, - { name: 'Ressourcen', path: '/ressourcen', icon: Boxes }, - { name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon }, - { name: 'Infoscreen-Clients', path: '/clients', icon: Monitor }, - { name: 'Erweiterungsmodus', path: '/setup', icon: Wrench }, - { name: 'Medien', path: '/medien', icon: Image }, - { name: 'Benutzer', path: '/benutzer', icon: User }, - { name: 'Einstellungen', path: '/einstellungen', icon: Settings }, - { name: 'Programminfo', path: '/programminfo', icon: Info }, + { name: 'Dashboard', path: '/', icon: LayoutDashboard, minRole: 'user' }, + { name: 'Termine', path: '/termine', icon: Calendar, minRole: 'user' }, + { name: 'Ressourcen', path: '/ressourcen', icon: Boxes, minRole: 'editor' }, + { name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon, minRole: 'admin' }, + { name: 'Infoscreen-Clients', path: '/clients', icon: Monitor, minRole: 'admin' }, + { name: 'Erweiterungsmodus', path: '/setup', icon: Wrench, minRole: 'admin' }, + { name: 'Medien', path: '/medien', icon: Image, minRole: 'editor' }, + { name: 'Benutzer', path: '/benutzer', icon: User, minRole: 'admin' }, + { name: 'Einstellungen', path: '/einstellungen', icon: Settings, minRole: 'admin' }, + { name: 'Programminfo', path: '/programminfo', icon: Info, minRole: 'user' }, ]; // Dummy Components (können in eigene Dateien ausgelagert werden) @@ -51,6 +52,8 @@ import Programminfo from './programminfo'; import Logout from './logout'; import Login from './login'; 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) // const ENV = import.meta.env.VITE_ENV || 'development'; @@ -60,8 +63,16 @@ const Layout: React.FC = () => { const [isCollapsed, setIsCollapsed] = useState(false); let sidebarRef: SidebarComponent | null; const { user } = useAuth(); + const toast = useToast(); 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(() => { fetch('/program-info.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 = () => (
{ 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 linkContent = ( { {user && ( { - if (args.item.id === 'profile') { - navigate('/benutzer'); + if (args.item.id === 'change-password') { + setPwdCurrent(''); + setPwdNew(''); + setPwdConfirm(''); + setShowPwdDialog(true); } else if (args.item.id === 'logout') { navigate('/logout'); } @@ -339,6 +389,57 @@ const Layout: React.FC = () => { )}
+ setShowPwdDialog(false)} + footerTemplate={() => ( +
+ setShowPwdDialog(false)} disabled={pwdBusy}> + Abbrechen + + + {pwdBusy ? 'Speichere...' : 'Speichern'} + +
+ )} + > +
+
+ + setPwdCurrent(e.value)} + disabled={pwdBusy} + /> +
+
+ + setPwdNew(e.value)} + disabled={pwdBusy} + /> +
+
+ + setPwdConfirm(e.value)} + disabled={pwdBusy} + /> +
+
+
diff --git a/dashboard/src/apiAuth.ts b/dashboard/src/apiAuth.ts index 70bb336..49f2104 100644 --- a/dashboard/src/apiAuth.ts +++ b/dashboard/src/apiAuth.ts @@ -31,6 +31,26 @@ export interface AuthCheckResponse { 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. * diff --git a/dashboard/src/apiUsers.ts b/dashboard/src/apiUsers.ts new file mode 100644 index 0000000..26105d9 --- /dev/null +++ b/dashboard/src/apiUsers.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/dashboard/src/users.tsx b/dashboard/src/users.tsx index 6241b4a..2f281bc 100644 --- a/dashboard/src/users.tsx +++ b/dashboard/src/users.tsx @@ -1,8 +1,822 @@ import React from 'react'; -const Benutzer: React.FC = () => ( -
-

Benutzer

-

Willkommen im Infoscreen-Management Benutzer.

-
-); +import { useAuth } from './useAuth'; +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Page, + Inject, + Toolbar, + Edit, + CommandColumn, + type EditSettingsModel, + type CommandModel, +} from '@syncfusion/ej2-react-grids'; +import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; +import { DialogComponent } from '@syncfusion/ej2-react-popups'; +import { ToastComponent } from '@syncfusion/ej2-react-notifications'; +import { TextBoxComponent } from '@syncfusion/ej2-react-inputs'; +import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns'; +import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons'; +import { + listUsers, + createUser, + updateUser, + resetUserPassword, + deleteUser, + type UserData, +} from './apiUsers'; + +const Benutzer: React.FC = () => { + const { user: currentUser } = useAuth(); + const [users, setUsers] = React.useState([]); + 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(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(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 = { + 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 ( + + {info.text} + + ); + }; + + // Status badge + const getStatusBadge = (isActive: boolean) => { + return ( + + {isActive ? 'Aktiv' : 'Inaktiv'} + + ); + }; + + // 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 ( +
+
Lade Benutzer...
+
+ ); + } + + return ( +
+ + + {/* Header */} +
+
+

Benutzerverwaltung

+

+ Verwalten Sie Benutzer und deren Rollen +

+
+ + Neuer Benutzer + +
+ + {/* Statistics */} +
+
+
Gesamt
+
{users.length}
+
+
+
Aktiv
+
+ {users.filter(u => u.isActive).length} +
+
+
+
Inaktiv
+
+ {users.filter(u => !u.isActive).length} +
+
+
+ + {/* Users Grid */} + + + + + getRoleBadge(props.role)} + /> + getStatusBadge(props.isActive)} + /> + formatDate(props.createdAt)} + /> + ( +
+ handleDetailsClick(props)} + > + Details + + handleEditClick(props)} + > + Bearbeiten + + handlePasswordClick(props)} + > + Passwort + + {isSuperadmin && currentUser?.id !== props.id && ( + handleDeleteClick(props)} + > + Löschen + + )} +
+ )} + /> +
+ +
+ + {/* Create User Dialog */} + setShowCreateDialog(false)} + footerTemplate={() => ( +
+ setShowCreateDialog(false)} + disabled={formBusy} + > + Abbrechen + + + {formBusy ? 'Erstelle...' : 'Erstellen'} + +
+ )} + > +
+
+ + setFormUsername(e.value)} + disabled={formBusy} + /> +
+ +
+ + setFormPassword(e.value)} + disabled={formBusy} + /> +
+ +
+ + setFormRole(e.value)} + disabled={formBusy} + /> +
+ +
+ setFormIsActive(e.checked)} + disabled={formBusy} + /> +
+
+
+ + {/* Edit User Dialog */} + setShowEditDialog(false)} + footerTemplate={() => ( +
+ setShowEditDialog(false)} + disabled={formBusy} + > + Abbrechen + + + {formBusy ? 'Speichere...' : 'Speichern'} + +
+ )} + > +
+ {selectedUser?.id === currentUser?.id && ( +
+ ⚠️ Sie bearbeiten Ihr eigenes Konto. Sie können Ihre eigene Rolle oder Ihren aktiven Status nicht ändern. +
+ )} + +
+ + setFormUsername(e.value)} + disabled={formBusy} + /> +
+ +
+ + setFormRole(e.value)} + disabled={formBusy || selectedUser?.id === currentUser?.id} + /> + {selectedUser?.id === currentUser?.id && ( +
+ Sie können Ihre eigene Rolle nicht ändern +
+ )} +
+ +
+ setFormIsActive(e.checked)} + disabled={formBusy || selectedUser?.id === currentUser?.id} + /> + {selectedUser?.id === currentUser?.id && ( +
+ Sie können Ihr eigenes Konto nicht deaktivieren +
+ )} +
+
+
+ + {/* Reset Password Dialog */} + setShowPasswordDialog(false)} + footerTemplate={() => ( +
+ setShowPasswordDialog(false)} + disabled={formBusy} + > + Abbrechen + + + {formBusy ? 'Setze zurück...' : 'Zurücksetzen'} + +
+ )} + > +
+
+ + setFormPassword(e.value)} + disabled={formBusy} + /> +
+ +
+ 💡 Das neue Passwort wird sofort wirksam. Informieren Sie den Benutzer über das neue Passwort. +
+
+
+ + {/* Delete User Dialog */} + setShowDeleteDialog(false)} + footerTemplate={() => ( +
+ setShowDeleteDialog(false)} + disabled={formBusy} + > + Abbrechen + + + {formBusy ? 'Lösche...' : 'Endgültig löschen'} + +
+ )} + > +
+
+ ⚠️ Warnung: Diese Aktion kann nicht rückgängig gemacht werden! +
+ +

+ Möchten Sie den Benutzer {selectedUser?.username} wirklich endgültig löschen? +

+ +

+ Tipp: Statt zu löschen, können Sie den Benutzer auch deaktivieren, um das Konto zu sperren und + gleichzeitig die Daten zu bewahren. +

+
+
+ + {/* Details Dialog */} + setShowDetailsDialog(false)} + footerTemplate={() => ( +
+ setShowDetailsDialog(false)}> + Schließen + +
+ )} + > +
+ {/* Account Info */} +
+

+ Kontoinformation +

+
+
+
Benutzer-ID
+
{selectedUser?.id}
+
+
+
Benutzername
+
{selectedUser?.username}
+
+
+
Rolle
+
{selectedUser ? getRoleBadge(selectedUser.role) : '-'}
+
+
+
Status
+
{selectedUser ? getStatusBadge(selectedUser.isActive) : '-'}
+
+
+
+ + {/* Security & Activity */} +
+

+ Sicherheit & Aktivität +

+
+
+
Letzter Login:
+
+ {selectedUser?.lastLoginAt ? formatDate(selectedUser.lastLoginAt) : 'Nie'} +
+
+
+
Passwort geändert:
+
+ {selectedUser?.lastPasswordChangeAt ? formatDate(selectedUser.lastPasswordChangeAt) : 'Nie'} +
+
+
+
Fehlgeschlagene Logins:
+
+ {selectedUser?.failedLoginAttempts || 0} +
+
+ {selectedUser?.lastFailedLoginAt && ( +
+
Letzter Fehler:
+
+ {formatDate(selectedUser.lastFailedLoginAt)} +
+
+ )} +
+
+ + {/* Deactivation Info (if applicable) */} + {selectedUser && !selectedUser.isActive && selectedUser.deactivatedAt && ( +
+
Konto deaktiviert
+
+ am {formatDate(selectedUser.deactivatedAt)} +
+
+ )} + + {/* Timestamps */} +
+

+ Zeitleisten +

+
+
+
Erstellt:
+
+ {selectedUser?.createdAt ? formatDate(selectedUser.createdAt) : '-'} +
+
+
+
Zuletzt geändert:
+
+ {selectedUser?.updatedAt ? formatDate(selectedUser.updatedAt) : '-'} +
+
+
+
+
+
+
+ ); +}; + export default Benutzer; diff --git a/models/models.py b/models/models.py index 3676c88..c1a9980 100644 --- a/models/models.py +++ b/models/models.py @@ -28,6 +28,13 @@ class User(Base): password_hash = Column(String(128), nullable=False) role = Column(Enum(UserRole), nullable=False, default=UserRole.user) 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), server_default=func.current_timestamp()) updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp( diff --git a/server/alembic/versions/4f0b8a3e5c20_add_user_audit_fields.py b/server/alembic/versions/4f0b8a3e5c20_add_user_audit_fields.py new file mode 100644 index 0000000..70cab31 --- /dev/null +++ b/server/alembic/versions/4f0b8a3e5c20_add_user_audit_fields.py @@ -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') diff --git a/server/routes/auth.py b/server/routes/auth.py index 17af5bd..65cded8 100644 --- a/server/routes/auth.py +++ b/server/routes/auth.py @@ -10,8 +10,10 @@ from flask import Blueprint, request, jsonify, session import os from server.database import Session from models.models import User, UserRole +from server.permissions import require_auth import bcrypt import sys +from datetime import datetime, timezone sys.path.append('/workspace') @@ -66,8 +68,17 @@ def login(): # Verify password 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 + # 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 session['user_id'] = user.id session['username'] = user.username @@ -173,6 +184,57 @@ def check_auth(): 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"]) def dev_login_superadmin(): """ diff --git a/server/routes/users.py b/server/routes/users.py new file mode 100644 index 0000000..1309f88 --- /dev/null +++ b/server/routes/users.py @@ -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("/", 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("/", 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("//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("/", 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() diff --git a/server/wsgi.py b/server/wsgi.py index e35f0c9..9b3781f 100644 --- a/server/wsgi.py +++ b/server/wsgi.py @@ -9,6 +9,7 @@ from server.routes.academic_periods import academic_periods_bp from server.routes.groups import groups_bp from server.routes.clients import clients_bp from server.routes.auth import auth_bp +from server.routes.users import users_bp from server.routes.system_settings import system_settings_bp from server.database import Session, engine from flask import Flask, jsonify, send_from_directory, request @@ -43,6 +44,7 @@ else: # Blueprints importieren und registrieren app.register_blueprint(auth_bp) +app.register_blueprint(users_bp) app.register_blueprint(clients_bp) app.register_blueprint(groups_bp) app.register_blueprint(events_bp)