Files
stackpulse/frontend/src/pages/dashboard/users.jsx
T
2025-11-12 10:48:25 +00:00

806 lines
28 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="mt-12 mb-8 flex flex-col gap-12">
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
>
<span>Benutzerverwaltung</span>
</Typography>
</CardHeader>
<CardBody className="pt-0">
{canManageUsers && (
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Benutzer anlegen
</Typography>
{hasSelectableGroups ? (
<>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
</Typography>
{createError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createError}
</div>
)}
{groupsError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Benutzername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Input
label="E-Mail (optional)"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Input
type="password"
label="Passwort"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Select
label="Globale Rolle"
variant="outlined"
value={newGroupId}
onChange={setNewGroupId}
disabled={groupSelectDisabled}
selected={renderSelectedGroup}
>
<Option value="">Bitte auswählen</Option>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
</div>
<div className="mt-4 text-sm text-stormGrey-500">
{groupsLoading
? "Benutzergruppen werden geladen ..."
: ""}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
</Button>
<Button
variant="text"
color="blue-gray"
onClick={resetNewUserForm}
disabled={creatingUser || maintenanceLocked}
>
Formular zurücksetzen
</Button>
</div>
</>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
</div>
)}
</div>
)}
<div className="mb-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
<div className="md:flex-1">
<Input
label="Suchen nach Name, E-Mail oder Gruppe"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !users.length}
crossOrigin=""
/>
</div>
<div className="md:mt-0 mt-8 md:flex-1">
<Select
variant="static"
label="Einträge pro Seite"
onChange={noop}
value={perPage}
>
{perPageOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</div>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</div>
)}
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
<th className="px-6 py-4 font-semibold">Benutzername</th>
<th className="px-6 py-4 font-semibold">E-Mail</th>
<th className="px-6 py-4 font-semibold">Gruppen</th>
<th className="px-6 py-4 font-semibold">Status</th>
<th className="px-6 py-4 font-semibold">Letzte Anmeldung</th>
<th className="px-6 py-4 font-semibold">Erstellt am</th>
<th className="px-6 py-4 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{loading && users.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
Benutzerdaten werden geladen ...
</td>
</tr>
) : paginatedUsers.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
Keine Benutzer gefunden.
</td>
</tr>
) : (
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 (
<tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
<Typography variant="small" className="font-medium text-stormGrey-900">
{user.username || ""}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">
{user.email || ""}
</Typography>
</td>
<td className="px-6 py-4">
{user.groups.length > 0 ? (
<div className="flex flex-wrap gap-2">
{user.groups.map((group) => (
<Chip
key={group.id ?? group.name}
value={group.name}
size="sm"
color="blue-gray"
variant="ghost"
/>
))}
</div>
) : (
<span className="text-stormGrey-400"></span>
)}
</td>
<td className="px-6 py-4">
{canManageUsers ? (
<button
type="button"
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceLocked ? "cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => handleToggleUserStatus(user)}
disabled={
maintenanceLocked ||
togglingUserId === user.id ||
(Array.isArray(user.groups) &&
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
}
>
<Chip
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm"
color={user.isActive ? "green" : "red"}
variant="ghost"
className="pointer-events-none"
/>
</button>
) : (
<Chip
value={user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm"
color={user.isActive ? "green" : "red"}
variant="ghost"
/>
)}
</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(user.lastLogin)}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(user.createdAt)}
</Typography>
</td>
<td className="px-6 py-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
<Button
size="sm"
variant="outlined"
color="blue-gray"
onClick={() => navigate(`/dashboard/users/${user.id}`)}
>
Details
</Button>
{canDeleteUsers && !isSuperuserUser && (
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteUser(user)}
disabled={maintenanceLocked || deletingUserId === user.id}
>
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
</Button>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Typography variant="small" color="gray">
{""}
</Typography>
<PaginationControls disabled={loading && users.length === 0} />
</div>
</CardBody>
</Card>
</div>
);
}
export default Users;