feat: document user management system and RBAC implementation

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

View File

@@ -104,6 +104,7 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- API builds its engine in `server/database.py` (loads `.env` only in development).
- 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` keyvalue store via `SystemSetting` for global configuration (e.g., WebUntis/Vertretungsplan supplement-table). Managed through routes in `server/routes/system_settings.py`.
- Presentation defaults (system-wide):
- `presentation_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/<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.
@@ -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

View File

@@ -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.01.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/<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
- `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

View File

@@ -5,6 +5,57 @@
This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`.
## 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):

View File

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

View File

@@ -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 = () => (
<div
className={`sidebar-theme ${isCollapsed ? 'collapsed' : 'expanded'}`}
@@ -132,7 +170,16 @@ const Layout: React.FC = () => {
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 = (
<Link
@@ -305,13 +352,16 @@ const Layout: React.FC = () => {
{user && (
<DropDownButtonComponent
items={[
{ text: 'Profil', id: 'profile', iconCss: 'e-icons e-user' },
{ text: 'Passwort ändern', id: 'change-password', iconCss: 'e-icons e-lock' },
{ separator: true },
{ text: 'Abmelden', id: 'logout', iconCss: 'e-icons e-logout' },
]}
select={(args: MenuEventArgs) => {
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 = () => {
)}
</div>
</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">
<Outlet />
</main>

View File

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

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

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

View File

@@ -1,8 +1,822 @@
import React from 'react';
const Benutzer: React.FC = () => (
<div>
<h2 className="text-xl font-bold mb-4">Benutzer</h2>
<p>Willkommen im Infoscreen-Management Benutzer.</p>
</div>
);
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<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;

View File

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

View File

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

View File

@@ -10,8 +10,10 @@ from flask import Blueprint, request, jsonify, session
import os
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():
"""

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

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

View File

@@ -9,6 +9,7 @@ from server.routes.academic_periods import academic_periods_bp
from server.routes.groups import groups_bp
from server.routes.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)