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

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