import React, { useCallback, useEffect, useMemo, useState } from "react"; import axios from "axios"; import { Card, CardHeader, CardBody, Typography, Chip, Button, Select, Option, Input } from "@material-tailwind/react"; import { useNavigate } from "react-router-dom"; import { PaginationControls, usePage } from "@/components/PageProvider.jsx"; import { useMaintenance } from "@/components/MaintenanceProvider.jsx"; import { useToast } from "@/components/ToastProvider.jsx"; import { useAuth } from "@/components/AuthProvider.jsx"; const UPDATE_STAGE_LABELS = { initializing: "Vorbereitung", "activating-maintenance": "Wartungsmodus aktivieren", "executing-script": "Skript wird ausgeführt", waiting: "Warte auf Portainer", completed: "Abgeschlossen", failed: "Fehlgeschlagen" }; const normalizeUserGroups = (rawGroups) => { if (!Array.isArray(rawGroups)) { return []; } return rawGroups .map((group) => { if (group && typeof group === "object") { const id = Number(group.id); const name = typeof group.name === "string" ? group.name : ""; if (!name) { return null; } return { id: Number.isFinite(id) ? id : null, name }; } if (typeof group === "string") { const name = group.trim(); return name ? { id: null, name } : null; } return null; }) .filter(Boolean); }; export function Users() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [availableGroups, setAvailableGroups] = useState([]); const [groupsLoading, setGroupsLoading] = useState(false); const [groupsError, setGroupsError] = useState(""); const [newUsername, setNewUsername] = useState(""); const [newEmail, setNewEmail] = useState(""); const [newPassword, setNewPassword] = useState(""); const [newGroupId, setNewGroupId] = useState(""); const [createError, setCreateError] = useState(""); const [creatingUser, setCreatingUser] = useState(false); const [deletingUserId, setDeletingUserId] = useState(null); const [togglingUserId, setTogglingUserId] = useState(null); const { showToast } = useToast(); const { maintenance: maintenanceMeta, update: updateState } = useMaintenance(); const maintenanceActive = Boolean(maintenanceMeta?.active); const maintenanceMessage = maintenanceMeta?.message; const updateRunning = Boolean(updateState?.running); const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–"; const maintenanceLocked = maintenanceActive || updateRunning; const navigate = useNavigate(); const noop = useCallback(() => { }, []); const { hasPermission } = useAuth(); const canManageUsers = hasPermission("users-edit", "full"); const canDeleteUsers = hasPermission("users-delete", "full"); const { page, perPage, perPageOptions, perPageIsAll, handlePerPageChange, setPage, setTotals, resetPagination } = usePage(); useEffect(() => () => resetPagination(), [resetPagination]); const fetchUsers = useCallback(async () => { setLoading(true); setError(""); try { const response = await axios.get("/api/users"); const items = Array.isArray(response.data?.items) ? response.data.items : []; const sorted = items .map((item) => ({ id: item.id, username: item.username || "", email: item.email || "", isActive: Boolean(item.isActive), avatarColor: typeof item.avatarColor === "string" ? item.avatarColor.trim() : null, lastLogin: item.lastLogin || null, createdAt: item.createdAt || null, updatedAt: item.updatedAt || null, groups: normalizeUserGroups(item.groups) })) .sort((a, b) => a.username.localeCompare(b.username, "de-DE")); setUsers(sorted); } catch (err) { const message = err.response?.data?.error || err.message || "Unbekannter Fehler"; setError(message); showToast({ variant: "error", title: "Benutzer konnten nicht geladen werden", description: message }); } finally { setLoading(false); } }, [showToast]); const fetchGroups = useCallback(async () => { setGroupsLoading(true); setGroupsError(""); try { const response = await axios.get("/api/groups"); const items = Array.isArray(response.data?.items) ? response.data.items : []; const normalized = items .map((item) => ({ id: Number(item.id), name: item.name || "" })) .filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name) .filter((group) => group.name.toLowerCase() !== "superuser") .sort((a, b) => a.name.localeCompare(b.name, "de-DE")); setAvailableGroups(normalized); } catch (err) { const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden."; setGroupsError(message); showToast({ variant: "error", title: "Benutzergruppen", description: message }); } finally { setGroupsLoading(false); } }, [showToast]); useEffect(() => { fetchUsers(); }, [fetchUsers]); useEffect(() => { fetchGroups(); }, [fetchGroups]); const handleRefresh = useCallback(() => { fetchUsers(); fetchGroups(); }, [fetchUsers, fetchGroups]); const handleSearchChange = useCallback((event) => { setSearchQuery(event.target.value); }, []); const handleClearSearch = useCallback(() => { setSearchQuery(""); }, []); useEffect(() => { if (page !== 1) { setPage(1); } }, [searchQuery, page, setPage]); const filteredUsers = useMemo(() => { const query = searchQuery.trim().toLowerCase(); if (!query) { return users; } return users.filter((user) => { const haystacks = [ user.username, user.email, ...user.groups.map((group) => group.name) ].map((value) => String(value || "").toLowerCase()); return haystacks.some((value) => value.includes(query)); }); }, [users, searchQuery]); const perPageNumber = useMemo(() => { if (perPageIsAll) { return null; } const numeric = Number(perPage); return Number.isFinite(numeric) && numeric > 0 ? numeric : null; }, [perPage, perPageIsAll]); const paginatedUsers = useMemo(() => { if (perPageIsAll || !perPageNumber) { return filteredUsers; } const startIndex = (page - 1) * perPageNumber; return filteredUsers.slice(startIndex, startIndex + perPageNumber); }, [filteredUsers, page, perPageIsAll, perPageNumber]); const filteredCount = filteredUsers.length; const visibleCount = paginatedUsers.length; useEffect(() => { if (perPageIsAll) { setTotals(filteredCount, filteredCount); if (page !== 1) { setPage(1); } return; } const numeric = Number(perPage); if (!Number.isFinite(numeric) || numeric <= 0) { setTotals(filteredCount, filteredCount); return; } const maxPages = Math.max(1, Math.ceil(filteredCount / numeric)); if (page > maxPages) { setPage(maxPages); return; } setTotals(filteredCount, visibleCount); }, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]); const formatTimestamp = useCallback((value) => { if (!value) { return "–"; } const normalized = typeof value === "string" ? value.replace(" ", "T") : value; const parsed = new Date(normalized); if (Number.isNaN(parsed.getTime())) { return value; } return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(parsed); }, []); const resetNewUserForm = useCallback(() => { setNewUsername(""); setNewEmail(""); setNewPassword(""); setNewGroupId(""); setCreateError(""); }, []); const handleDeleteUser = useCallback(async (user) => { if (maintenanceLocked || !user?.id) { return; } if (!canDeleteUsers) { return; } const isSuperuserUser = Array.isArray(user?.groups) ? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser") : false; if (isSuperuserUser) { showToast({ variant: "error", title: "Löschen nicht möglich", description: "Der Superuser kann nicht gelöscht werden." }); return; } const confirmation = window.confirm(`Benutzer "${user.username}" wirklich löschen?`); if (!confirmation) { return; } const numericId = Number(user.id); if (!Number.isFinite(numericId) || numericId <= 0) { showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: "Ungültige Benutzer-ID." }); return; } setDeletingUserId(numericId); try { await axios.delete(`/api/users/${numericId}`); showToast({ variant: "success", title: "Benutzer gelöscht", description: `Der Benutzer "${user.username}" wurde entfernt.` }); fetchUsers(); } catch (err) { const serverError = err.response?.data?.error; let message = "Der Benutzer konnte nicht gelöscht werden."; if (serverError === "INVALID_USER_ID") { message = "Die Benutzer-ID ist ungültig."; } else if (serverError === "USER_NOT_FOUND") { message = "Der Benutzer wurde bereits entfernt."; } else if (serverError === "USER_SUPERUSER_PROTECTED") { message = "Der Superuser kann nicht gelöscht werden."; } showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: message }); } finally { setDeletingUserId(null); } }, [maintenanceLocked, canDeleteUsers, showToast, fetchUsers]); const handleToggleUserStatus = useCallback(async (user) => { if (!user?.id) { return; } if (!canManageUsers) { return; } if (maintenanceLocked) { showToast({ variant: "error", title: "Aktion nicht möglich", description: "Im Wartungsmodus oder während eines Updates sind Statusänderungen gesperrt." }); return; } const isSuperuserUser = Array.isArray(user?.groups) ? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser") : false; if (isSuperuserUser && user.isActive) { showToast({ variant: "error", title: "Änderung nicht möglich", description: "Der Superuser kann nicht deaktiviert werden." }); return; } const numericId = Number(user.id); if (!Number.isFinite(numericId) || numericId <= 0) { showToast({ variant: "error", title: "Statusänderung fehlgeschlagen", description: "Ungültige Benutzer-ID." }); return; } const nextActive = !user.isActive; setTogglingUserId(numericId); try { await axios.put(`/api/users/${numericId}/active`, { isActive: nextActive }); fetchUsers(); showToast({ variant: "success", title: "Status aktualisiert", description: `Der Benutzer wurde ${nextActive ? "aktiviert" : "deaktiviert"}.` }); } catch (err) { const serverError = err.response?.data?.error; let message = "Der Benutzerstatus konnte nicht geändert werden."; if (serverError === "INVALID_USER_ID") { message = "Die Benutzer-ID ist ungültig."; } else if (serverError === "USER_NOT_FOUND") { message = "Der Benutzer wurde nicht gefunden."; } else if (serverError === "USER_SUPERUSER_PROTECTED") { message = "Der Superuser kann nicht deaktiviert werden."; } showToast({ variant: "error", title: "Statusänderung fehlgeschlagen", description: message }); } finally { setTogglingUserId(null); } }, [maintenanceLocked, canManageUsers, showToast, fetchUsers]); const handleCreateUser = useCallback(async () => { if (maintenanceLocked) { setCreateError("Im Wartungsmodus oder während eines Updates können keine Benutzer angelegt werden."); return; } if (!canManageUsers) { setCreateError("Du verfügst nicht über die Berechtigung zum Anlegen von Benutzern."); return; } const trimmedUsername = newUsername.trim(); if (!trimmedUsername) { setCreateError("Benutzername ist erforderlich."); return; } const sanitizedPassword = typeof newPassword === "string" ? newPassword.trim() : ""; if (sanitizedPassword.length < 8) { setCreateError("Passwort muss mindestens 8 Zeichen enthalten."); return; } if (!newGroupId) { setCreateError("Bitte eine globale Rolle auswählen."); return; } setCreatingUser(true); setCreateError(""); try { const payload = { username: trimmedUsername, password: sanitizedPassword, groupId: Number(newGroupId) }; const trimmedEmail = newEmail.trim(); if (trimmedEmail) { payload.email = trimmedEmail; } const response = await axios.post("/api/users", payload); resetNewUserForm(); fetchUsers(); showToast({ variant: "success", title: "Benutzer angelegt", description: `Der Benutzer "${response.data?.item?.username ?? trimmedUsername}" wurde erstellt.` }); } catch (err) { const errorCode = err.response?.data?.error; let message = "Benutzer konnte nicht angelegt werden."; switch (errorCode) { case "USERNAME_REQUIRED": message = "Benutzername ist erforderlich."; break; case "INVALID_EMAIL": message = "Bitte eine gültige E-Mail-Adresse angeben."; break; case "INVALID_PASSWORD": case "PASSWORD_TOO_SHORT": message = "Passwort muss mindestens 8 Zeichen enthalten."; break; case "INVALID_GROUP_ID": case "GROUP_NOT_FOUND": message = "Bitte eine gültige globale Rolle auswählen."; break; case "USERNAME_TAKEN": message = "Benutzername wird bereits verwendet."; break; case "EMAIL_TAKEN": message = "E-Mail-Adresse wird bereits verwendet."; break; default: break; } setCreateError(message); showToast({ variant: "error", title: "Erstellen fehlgeschlagen", description: message }); } finally { setCreatingUser(false); } }, [ maintenanceLocked, canManageUsers, newUsername, newEmail, newPassword, newGroupId, showToast, resetNewUserForm, fetchUsers ]); const hasSelectableGroups = availableGroups.length > 0; const createDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups; const groupSelectDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups; const renderSelectedGroup = useCallback( (element) => { if (element && element.props && element.props.children) { return element.props.children; } if (!newGroupId) { return "Bitte auswählen"; } const selected = availableGroups.find((group) => String(group.id) === newGroupId); return selected ? selected.name : "Bitte auswählen"; }, [availableGroups, newGroupId] ); return (
{(maintenanceActive || updateRunning) && (
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}. {updateRunning && ( Phase: {updateStageLabel} )}
)} Benutzerverwaltung {canManageUsers && (
Neuen Benutzer anlegen {hasSelectableGroups ? ( <> Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder. {createError && (
{createError}
)} {groupsError && (
{groupsError}
)}
setNewUsername(event.target.value)} disabled={maintenanceLocked || creatingUser} crossOrigin="" /> setNewEmail(event.target.value)} disabled={maintenanceLocked || creatingUser} crossOrigin="" /> setNewPassword(event.target.value)} disabled={maintenanceLocked || creatingUser} crossOrigin="" />
{groupsLoading ? "Benutzergruppen werden geladen ..." : ""}
) : (
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
)}
)}
{error && (
{error}
)}
{loading && users.length === 0 ? ( ) : paginatedUsers.length === 0 ? ( ) : ( paginatedUsers.map((user, index) => { const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50"; const isSuperuserUser = Array.isArray(user?.groups) ? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser") : false; return ( ); }) )}
Benutzername E-Mail Gruppen Status Letzte Anmeldung Erstellt am Aktionen
Benutzerdaten werden geladen ...
Keine Benutzer gefunden.
{user.username || "–"} {user.email || "–"} {user.groups.length > 0 ? (
{user.groups.map((group) => ( ))}
) : ( )}
{canManageUsers ? ( ) : ( )} {formatTimestamp(user.lastLogin)} {formatTimestamp(user.createdAt)}
{canDeleteUsers && !isSuperuserUser && ( )}
{""}
); } export default Users;