User, Groups, Logs Update
This commit is contained in:
@@ -90,27 +90,24 @@ export function SignIn() {
|
||||
body: JSON.stringify({ identifier: trimmedIdentifier, password: trimmedPassword }),
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
navigate("/setup", { replace: true });
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 || payload.error === "INVALID_CREDENTIALS") {
|
||||
setError("Ungültige Zugangsdaten.");
|
||||
} else if (payload.error === "MISSING_CREDENTIALS") {
|
||||
setError("Bitte fülle beide Felder aus.");
|
||||
} else if (payload.error === "SETUP_REQUIRED") {
|
||||
} else if (payload.error === "SETUP_REQUIRED" || payload.error === "SUPERUSER_REQUIRED") {
|
||||
navigate("/setup", { replace: true });
|
||||
return;
|
||||
} else if (payload.error === "USER_INACTIVE") {
|
||||
setError("Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.");
|
||||
} else {
|
||||
setError("Anmeldung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await response.json().catch(() => ({}));
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
} catch (err) {
|
||||
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "@/pages/dashboard/logs";
|
||||
export * from "@/pages/dashboard/users";
|
||||
export * from "@/pages/dashboard/usergroups";
|
||||
export * from "@/pages/dashboard/userDetails";
|
||||
export * from "@/pages/dashboard/userGroupDetail";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Typography,
|
||||
Alert,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
} from "@material-tailwind/react";
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function Notifications() {
|
||||
const [showAlerts, setShowAlerts] = React.useState({
|
||||
blue: true,
|
||||
green: true,
|
||||
orange: true,
|
||||
red: true,
|
||||
});
|
||||
const [showAlertsWithIcon, setShowAlertsWithIcon] = React.useState({
|
||||
blue: true,
|
||||
green: true,
|
||||
orange: true,
|
||||
red: true,
|
||||
});
|
||||
const alerts = ["gray", "green", "orange", "red"];
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-20 flex max-w-screen-lg flex-col gap-8">
|
||||
<Card>
|
||||
<CardHeader
|
||||
color="transparent"
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="m-0 p-4"
|
||||
>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Alerts
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4 p-4">
|
||||
{alerts.map((color) => (
|
||||
<Alert
|
||||
key={color}
|
||||
open={showAlerts[color]}
|
||||
color={color}
|
||||
onClose={() => setShowAlerts((current) => ({ ...current, [color]: false }))}
|
||||
>
|
||||
A simple {color} alert with an <a href="#">example link</a>. Give
|
||||
it a click if you like.
|
||||
</Alert>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader
|
||||
color="transparent"
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="m-0 p-4"
|
||||
>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Alerts with Icon
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4 p-4">
|
||||
{alerts.map((color) => (
|
||||
<Alert
|
||||
key={color}
|
||||
open={showAlertsWithIcon[color]}
|
||||
color={color}
|
||||
icon={
|
||||
<InformationCircleIcon strokeWidth={2} className="h-6 w-6" />
|
||||
}
|
||||
onClose={() => setShowAlertsWithIcon((current) => ({
|
||||
...current,
|
||||
[color]: false,
|
||||
}))}
|
||||
>
|
||||
A simple {color} alert with an <a href="#">example link</a>. Give
|
||||
it a click if you like.
|
||||
</Alert>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notifications;
|
||||
@@ -1,221 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
Avatar,
|
||||
Typography,
|
||||
Tabs,
|
||||
TabsHeader,
|
||||
Tab,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
HomeIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
Cog6ToothIcon,
|
||||
PencilIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ProfileInfoCard, MessageCard } from "@/widgets/cards";
|
||||
import { platformSettingsData, conversationsData, projectsData } from "@/data";
|
||||
|
||||
export function Profile() {
|
||||
return (
|
||||
<>
|
||||
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover bg-center">
|
||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||
</div>
|
||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||
<CardBody className="p-4">
|
||||
<div className="mb-10 flex items-center justify-between flex-wrap gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar
|
||||
src="/img/bruce-mars.jpeg"
|
||||
alt="bruce-mars"
|
||||
size="xl"
|
||||
variant="rounded"
|
||||
className="rounded-lg shadow-lg shadow-blue-gray-500/40"
|
||||
/>
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray" className="mb-1">
|
||||
Richard Davis
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-600"
|
||||
>
|
||||
CEO / Co-Founder
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-96">
|
||||
<Tabs value="app">
|
||||
<TabsHeader>
|
||||
<Tab value="app">
|
||||
<HomeIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
|
||||
App
|
||||
</Tab>
|
||||
<Tab value="message">
|
||||
<ChatBubbleLeftEllipsisIcon className="-mt-0.5 mr-2 inline-block h-5 w-5" />
|
||||
Message
|
||||
</Tab>
|
||||
<Tab value="settings">
|
||||
<Cog6ToothIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
|
||||
Settings
|
||||
</Tab>
|
||||
</TabsHeader>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gird-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||
Platform Settings
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-12">
|
||||
{platformSettingsData.map(({ title, options }) => (
|
||||
<div key={title}>
|
||||
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
{title}
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-6">
|
||||
{options.map(({ checked, label }) => (
|
||||
<Switch
|
||||
key={label}
|
||||
id={label}
|
||||
label={label}
|
||||
defaultChecked={checked}
|
||||
labelProps={{
|
||||
className: "text-sm font-normal text-blue-gray-500",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ProfileInfoCard
|
||||
title="Profile Information"
|
||||
description="Hi, I'm Alec Thompson, Decisions: If you can't decide, the answer is no. If two equally difficult paths, choose the one more painful in the short term (pain avoidance is creating an illusion of equality)."
|
||||
details={{
|
||||
"first name": "Alec M. Thompson",
|
||||
mobile: "(44) 123 1234 123",
|
||||
email: "alecthompson@mail.com",
|
||||
location: "USA",
|
||||
social: (
|
||||
<div className="flex items-center gap-4">
|
||||
<i className="fa-brands fa-facebook text-blue-700" />
|
||||
<i className="fa-brands fa-twitter text-blue-400" />
|
||||
<i className="fa-brands fa-instagram text-purple-500" />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
action={
|
||||
<Tooltip content="Edit Profile">
|
||||
<PencilIcon className="h-4 w-4 cursor-pointer text-blue-gray-500" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||
Platform Settings
|
||||
</Typography>
|
||||
<ul className="flex flex-col gap-6">
|
||||
{conversationsData.map((props) => (
|
||||
<MessageCard
|
||||
key={props.name}
|
||||
{...props}
|
||||
action={
|
||||
<Button variant="text" size="sm">
|
||||
reply
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||
Projects
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
Architects design houses
|
||||
</Typography>
|
||||
<div className="mt-6 grid grid-cols-1 gap-12 md:grid-cols-2 xl:grid-cols-4">
|
||||
{projectsData.map(
|
||||
({ img, title, description, tag, route, members }) => (
|
||||
<Card key={title} color="transparent" shadow={false}>
|
||||
<CardHeader
|
||||
floated={false}
|
||||
color="gray"
|
||||
className="mx-0 mt-0 mb-4 h-64 xl:h-40"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody className="py-0 px-1">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{tag}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
color="blue-gray"
|
||||
className="mt-1 mb-2"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</CardBody>
|
||||
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1">
|
||||
<Link to={route}>
|
||||
<Button variant="outlined" size="sm">
|
||||
view project
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
{members.map(({ img, name }, key) => (
|
||||
<Tooltip key={name} content={name}>
|
||||
<Avatar
|
||||
src={img}
|
||||
alt={name}
|
||||
size="xs"
|
||||
variant="circular"
|
||||
className={`cursor-pointer border-2 border-white ${
|
||||
key === 0 ? "" : "-ml-2.5"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Profile;
|
||||
@@ -1,34 +1,25 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Button,
|
||||
Spinner,
|
||||
CardFooter,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
Select,
|
||||
Option
|
||||
Option,
|
||||
Input,
|
||||
Switch
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
PencilIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { ProfileInfoCard } from "@/widgets/cards";
|
||||
import { platformSettingsData, projectsData } from "@/data";
|
||||
import { platformSettingsData } from "@/data";
|
||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
|
||||
const _ = AVATAR_COLORS.join(" ");
|
||||
|
||||
|
||||
const normalizeUserGroups = (rawGroups) => {
|
||||
if (!Array.isArray(rawGroups)) {
|
||||
return [];
|
||||
@@ -67,47 +58,74 @@ const mapUser = (item) => ({
|
||||
groups: normalizeUserGroups(item?.groups)
|
||||
});
|
||||
|
||||
const extractPrimaryGroupId = (user) => {
|
||||
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const firstValid = user.groups
|
||||
.map((group) => Number(group.id))
|
||||
.find((id) => Number.isFinite(id) && id > 0);
|
||||
return Number.isFinite(firstValid) ? firstValid : null;
|
||||
};
|
||||
|
||||
const buildInitialFormValues = (user) => {
|
||||
if (!user) {
|
||||
return {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
groupId: null,
|
||||
avatarColor: ""
|
||||
};
|
||||
}
|
||||
|
||||
const primaryGroupId = extractPrimaryGroupId(user);
|
||||
|
||||
return {
|
||||
username: user.username || "",
|
||||
email: user.email || "",
|
||||
password: "",
|
||||
groupId: Number.isFinite(primaryGroupId) ? primaryGroupId : null,
|
||||
avatarColor: user.avatarColor || ""
|
||||
};
|
||||
};
|
||||
|
||||
export function UserDetails() {
|
||||
const { userId } = useParams();
|
||||
const { showToast } = useToast();
|
||||
const { maintenance } = useMaintenance();
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [availableGroups, setAvailableGroups] = useState([]);
|
||||
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||
const [groupsError, setGroupsError] = useState("");
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(null);
|
||||
const [savingGroup, setSavingGroup] = useState(false);
|
||||
const [savingUser, setSavingUser] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const isSuperuserUser = useMemo(() => {
|
||||
if (!Array.isArray(user?.groups)) {
|
||||
return false;
|
||||
}
|
||||
return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser");
|
||||
}, [user]);
|
||||
|
||||
const numericUserId = useMemo(() => {
|
||||
const asNumber = Number(userId);
|
||||
return Number.isFinite(asNumber) ? asNumber : null;
|
||||
}, [userId]);
|
||||
|
||||
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 fetchUserDetails = useCallback(async () => {
|
||||
if (!numericUserId) {
|
||||
setError("Ungültige Benutzer-ID.");
|
||||
setUser(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +140,10 @@ export function UserDetails() {
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
setUser(item);
|
||||
setError("");
|
||||
const initialValues = buildInitialFormValues(item);
|
||||
initialFormValuesRef.current = { ...initialValues };
|
||||
setFormValues(initialValues);
|
||||
setSaveError("");
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Benutzerdetails konnten nicht geladen werden.";
|
||||
@@ -136,6 +157,8 @@ export function UserDetails() {
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -185,340 +208,415 @@ export function UserDetails() {
|
||||
fetchAvailableGroups();
|
||||
}, [fetchAvailableGroups]);
|
||||
|
||||
const originalGroupId = useMemo(() => {
|
||||
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const firstValid = user.groups
|
||||
.map((group) => Number(group.id))
|
||||
.find((id) => Number.isFinite(id) && id > 0);
|
||||
return Number.isFinite(firstValid) ? firstValid : null;
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedGroupId(originalGroupId);
|
||||
}, [originalGroupId]);
|
||||
|
||||
const hasGroupChanges = useMemo(() => {
|
||||
if (!hasLoaded) {
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!hasLoaded || !user) {
|
||||
return false;
|
||||
}
|
||||
return originalGroupId !== selectedGroupId;
|
||||
}, [originalGroupId, selectedGroupId, hasLoaded]);
|
||||
|
||||
const initial = initialFormValuesRef.current;
|
||||
if (!initial) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialUsername = initial.username || "";
|
||||
const currentUsername = formValues.username || "";
|
||||
|
||||
const initialEmail = initial.email || "";
|
||||
const currentEmail = formValues.email || "";
|
||||
|
||||
const initialGroupId = Number.isFinite(initial.groupId) ? Number(initial.groupId) : null;
|
||||
const currentGroupId = Number.isFinite(formValues.groupId) ? Number(formValues.groupId) : null;
|
||||
|
||||
const initialAvatar = initial.avatarColor || "";
|
||||
const currentAvatar = formValues.avatarColor || "";
|
||||
|
||||
const passwordChanged = Boolean(formValues.password && formValues.password.trim().length > 0);
|
||||
|
||||
return (
|
||||
initialUsername !== currentUsername ||
|
||||
initialEmail !== currentEmail ||
|
||||
(!isSuperuserUser && initialGroupId !== currentGroupId) ||
|
||||
initialAvatar !== currentAvatar ||
|
||||
passwordChanged
|
||||
);
|
||||
}, [formValues, hasLoaded, user, isSuperuserUser]);
|
||||
|
||||
const renderSelectedAvatarLabel = useCallback(
|
||||
(element) => {
|
||||
if (element?.props?.children) {
|
||||
return element.props.children;
|
||||
}
|
||||
if (!formValues.avatarColor) {
|
||||
return "Standardfarbe";
|
||||
}
|
||||
return formValues.avatarColor;
|
||||
},
|
||||
[formValues.avatarColor]
|
||||
);
|
||||
|
||||
const handleUsernameChange = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
username: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleEmailChange = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
email: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePasswordChange = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
password: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleGroupChange = useCallback((value) => {
|
||||
if (!value) {
|
||||
setSelectedGroupId(null);
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
groupId: null
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
setSelectedGroupId(Number.isFinite(numeric) && numeric > 0 ? numeric : null);
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResetGroup = useCallback(() => {
|
||||
setSelectedGroupId(originalGroupId);
|
||||
}, [originalGroupId]);
|
||||
const handleAvatarColorChange = useCallback((value) => {
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
avatarColor: value || ""
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSaveGroup = useCallback(async () => {
|
||||
if (!user || !hasGroupChanges) {
|
||||
const handleSaveUser = useCallback(async () => {
|
||||
if (!user || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadGroups = selectedGroupId ? [selectedGroupId] : [];
|
||||
setSavingUser(true);
|
||||
setSaveError("");
|
||||
|
||||
setSavingGroup(true);
|
||||
setGroupsError("");
|
||||
try {
|
||||
const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups });
|
||||
const payload = {
|
||||
username: formValues.username,
|
||||
email: formValues.email,
|
||||
password: formValues.password,
|
||||
avatarColor: formValues.avatarColor
|
||||
};
|
||||
|
||||
if (!isSuperuserUser) {
|
||||
payload.groupId = formValues.groupId;
|
||||
}
|
||||
|
||||
const response = await axios.put(`/api/users/${user.id}`, payload);
|
||||
const updatedUser = mapUser(response.data?.item || response.data?.user);
|
||||
setUser(updatedUser);
|
||||
const nextGroupId = Array.isArray(updatedUser.groups) && updatedUser.groups.length > 0
|
||||
? Number(updatedUser.groups[0].id)
|
||||
: null;
|
||||
setSelectedGroupId(Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null);
|
||||
const nextInitial = buildInitialFormValues(updatedUser);
|
||||
initialFormValuesRef.current = { ...nextInitial };
|
||||
setFormValues(nextInitial);
|
||||
setGroupsError("");
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Globale Rolle aktualisiert",
|
||||
description: "Die Gruppenzuordnung wurde gespeichert."
|
||||
title: "Benutzer gespeichert",
|
||||
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||
});
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = err.message || "Die globale Rolle konnte nicht aktualisiert werden.";
|
||||
if (serverError === "INVALID_USER_ID") {
|
||||
message = "Die Benutzer-ID ist ungültig.";
|
||||
} else if (serverError === "USER_NOT_FOUND") {
|
||||
message = "Der Benutzer konnte nicht gefunden werden.";
|
||||
let message = "Die Benutzerdaten konnten nicht gespeichert werden.";
|
||||
|
||||
if (serverError === "USERNAME_REQUIRED") {
|
||||
message = "Bitte einen Benutzernamen angeben.";
|
||||
} else if (serverError === "USERNAME_TAKEN") {
|
||||
message = "Der Benutzername wird bereits verwendet.";
|
||||
} else if (serverError === "INVALID_EMAIL") {
|
||||
message = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||
} else if (serverError === "EMAIL_TAKEN") {
|
||||
message = "Die E-Mail-Adresse wird bereits verwendet.";
|
||||
} else if (serverError === "INVALID_PASSWORD") {
|
||||
message = "Das Passwort ist ungültig.";
|
||||
} else if (serverError === "PASSWORD_TOO_SHORT") {
|
||||
message = "Das Passwort muss mindestens 8 Zeichen enthalten.";
|
||||
} else if (serverError === "INVALID_AVATAR_COLOR") {
|
||||
message = "Bitte eine gültige Avatar-Farbe auswählen.";
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die ausgewählte Gruppe existiert nicht mehr.";
|
||||
} else if (typeof serverError === "string" && serverError.length > 0) {
|
||||
message = serverError;
|
||||
message = "Die ausgewählte Benutzergruppe existiert nicht mehr.";
|
||||
setGroupsError(message);
|
||||
}
|
||||
setGroupsError(message);
|
||||
|
||||
setSaveError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Globale Rolle",
|
||||
title: "Speichern fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setSavingGroup(false);
|
||||
setSavingUser(false);
|
||||
}
|
||||
}, [user, selectedGroupId, hasGroupChanges, showToast]);
|
||||
|
||||
const selectedGroupLabel = useMemo(() => {
|
||||
if (!selectedGroupId) {
|
||||
return null;
|
||||
}
|
||||
const match = availableGroups.find((group) => group.id === selectedGroupId);
|
||||
return match ? match.name : null;
|
||||
}, [availableGroups, selectedGroupId]);
|
||||
|
||||
const selectValue = selectedGroupId ? String(selectedGroupId) : "";
|
||||
const selectDisabled = maintenanceActive || savingGroup || !user || groupsLoading;
|
||||
}, [user, hasChanges, formValues, showToast, isSuperuserUser]);
|
||||
|
||||
const avatarLabel = useMemo(() => {
|
||||
const source = (user?.username || user?.email || "").trim();
|
||||
const source = (formValues.username || formValues.email || "").trim();
|
||||
if (!source) {
|
||||
return "?";
|
||||
}
|
||||
return source.charAt(0).toUpperCase();
|
||||
}, [user]);
|
||||
}, [formValues.username, formValues.email]);
|
||||
|
||||
const avatarColorClass = useMemo(() => {
|
||||
if (formValues.avatarColor) {
|
||||
return formValues.avatarColor;
|
||||
}
|
||||
return user?.avatarColor || "";
|
||||
}, [formValues.avatarColor, user]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!Array.isArray(availableGroups)) {
|
||||
return [];
|
||||
}
|
||||
return availableGroups.filter((group) => (group?.name || "").toLowerCase() !== "superuser");
|
||||
}, [availableGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperuserUser) {
|
||||
return;
|
||||
}
|
||||
const currentGroupId = formValues.groupId ? Number(formValues.groupId) : null;
|
||||
const stillExists = filteredGroups.some((group) => Number(group.id) === currentGroupId);
|
||||
if (!stillExists) {
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
groupId: null
|
||||
}));
|
||||
}
|
||||
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
|
||||
|
||||
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading;
|
||||
const avatarSelectDisabled = maintenanceActive || savingUser || !user;
|
||||
const inputDisabled = maintenanceActive || savingUser || !user;
|
||||
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover bg-center">
|
||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||
</div>
|
||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||
<CardBody className="p-4">
|
||||
<div className="mb-10 flex items-center justify-between flex-wrap gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${user?.avatarColor}`}
|
||||
aria-label={user?.username || "Benutzeravatar"}
|
||||
>
|
||||
{avatarLabel}
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray" className="mb-1">
|
||||
{user?.username}
|
||||
</Typography>
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wide text-stormGrey-400">
|
||||
User-ID: {user?.id || "–"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col gap-12">
|
||||
{maintenanceActive && (
|
||||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Wartungsmodus aktiv – Änderungen sind deaktiviert.
|
||||
</div>
|
||||
{loading && !user && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Benutzerdaten werden geladen ...</span>
|
||||
)}
|
||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||
</div>
|
||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||
<CardBody className="p-4">
|
||||
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${avatarColorClass}`}
|
||||
aria-label={formValues.username || "Benutzeravatar"}
|
||||
>
|
||||
{avatarLabel}
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
{formValues.username || "–"}
|
||||
</Typography>
|
||||
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
|
||||
{formValues.email || "–"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
color="green"
|
||||
className="normal-case"
|
||||
onClick={handleSaveUser}
|
||||
disabled={maintenanceActive || savingUser}
|
||||
>
|
||||
{savingUser ? "Speichert ..." : "Änderungen speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="gird-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||
Platform Settings
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-12">
|
||||
{platformSettingsData.map(({ title, options }) => (
|
||||
<div key={title}>
|
||||
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
{title}
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-6">
|
||||
{options.map(({ checked, label }) => (
|
||||
<Switch
|
||||
key={label}
|
||||
id={label}
|
||||
label={label}
|
||||
defaultChecked={checked}
|
||||
labelProps={{
|
||||
className: "text-sm font-normal text-blue-gray-500",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{loading && !user && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Benutzerdaten werden geladen ...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{saveError && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||
Allgemeine Einstellungen
|
||||
</Typography>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Benutzername
|
||||
</Typography>
|
||||
<Input
|
||||
value={formValues.username}
|
||||
onChange={handleUsernameChange}
|
||||
placeholder="Benutzername"
|
||||
disabled={inputDisabled}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
E-Mail-Adresse
|
||||
</Typography>
|
||||
<Input
|
||||
type="email"
|
||||
value={formValues.email}
|
||||
onChange={handleEmailChange}
|
||||
placeholder="benutzer@example.com"
|
||||
disabled={inputDisabled}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Neues Passwort
|
||||
</Typography>
|
||||
<Input
|
||||
type="password"
|
||||
value={formValues.password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="Passwort setzen"
|
||||
disabled={inputDisabled}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
<Typography className="mt-1 text-xs text-blue-gray-400">
|
||||
Das Passwort wird nur geändert, wenn ein neuer Wert eingetragen wird.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Globale Rolle
|
||||
</Typography>
|
||||
{groupsError && !isSuperuserUser && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{groupsError}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ProfileInfoCard
|
||||
title="Profile Information"
|
||||
description="Hi, I'm Alec Thompson, Decisions: If you can't decide, the answer is no. If two equally difficult paths, choose the one more painful in the short term (pain avoidance is creating an illusion of equality)."
|
||||
details={{
|
||||
"first name": "Alec M. Thompson",
|
||||
mobile: "(44) 123 1234 123",
|
||||
email: "alecthompson@mail.com",
|
||||
location: "USA",
|
||||
social: (
|
||||
<div className="flex items-center gap-4">
|
||||
<i className="fa-brands fa-facebook text-blue-700" />
|
||||
<i className="fa-brands fa-twitter text-blue-400" />
|
||||
<i className="fa-brands fa-instagram text-purple-500" />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
action={
|
||||
<Tooltip content="Edit Profile">
|
||||
<PencilIcon className="h-4 w-4 cursor-pointer text-blue-gray-500" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<div id="platform" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
Globale Rolle
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
color="blue-gray"
|
||||
className="normal-case"
|
||||
onClick={handleResetGroup}
|
||||
disabled={!hasGroupChanges || savingGroup || !user}
|
||||
>
|
||||
Änderungen verwerfen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="green"
|
||||
className="normal-case"
|
||||
onClick={handleSaveGroup}
|
||||
disabled={maintenanceActive || savingGroup || !hasGroupChanges || !user}
|
||||
>
|
||||
{savingGroup ? "Speichert ..." : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{maintenanceActive && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Wartungsmodus aktiv – Änderungen sind deaktiviert.
|
||||
</div>
|
||||
)}
|
||||
{groupsError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{groupsError}
|
||||
</div>
|
||||
)}
|
||||
{groupsLoading && availableGroups.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Benutzergruppen werden geladen ...</span>
|
||||
</div>
|
||||
) : availableGroups.length === 0 ? (
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500">
|
||||
Es sind noch keine Benutzergruppen vorhanden.
|
||||
</Typography>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="sm:flex-1">
|
||||
)}
|
||||
{isSuperuserUser ? (
|
||||
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Die Rolle für den Superuser kann nicht geändert werden.
|
||||
</Typography>
|
||||
) : groupsLoading && availableGroups.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Benutzergruppen werden geladen ...</span>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Es sind keine weiteren Benutzergruppen vorhanden. Bitte lege zunächst eine neue Gruppe an.
|
||||
</Typography>
|
||||
) : (
|
||||
<Select
|
||||
label="Benutzergruppe wählen"
|
||||
value={selectValue}
|
||||
value={groupSelectValue}
|
||||
onChange={handleGroupChange}
|
||||
disabled={selectDisabled}
|
||||
variant="outlined"
|
||||
>
|
||||
{availableGroups.map((group) => (
|
||||
{filteredGroups.map((group) => (
|
||||
<Option key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!(groupsLoading && availableGroups.length === 0) && (
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500">
|
||||
{selectedGroupLabel
|
||||
? `Aktuell zugewiesene Gruppe: ${selectedGroupLabel}`
|
||||
: "Dem Benutzer ist aktuell keine Gruppe zugewiesen."}
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Avatar-Farbe
|
||||
</Typography>
|
||||
<Select
|
||||
label="Avatar-Farbe auswählen"
|
||||
variant="outlined"
|
||||
value={formValues.avatarColor}
|
||||
onChange={handleAvatarColorChange}
|
||||
disabled={avatarSelectDisabled}
|
||||
selected={renderSelectedAvatarLabel}
|
||||
>
|
||||
<Option value="">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded border border-blue-gray-100" />
|
||||
<span className="text-xs">Keine</span>
|
||||
</span>
|
||||
</Option>
|
||||
{AVATAR_COLORS.map((color) => (
|
||||
<Option key={color} value={color}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||||
<span className="text-xs">{color}</span>
|
||||
</span>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||
Platform Settings
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||
Projects
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
Architects design houses
|
||||
</Typography>
|
||||
<div className="mt-6 grid grid-cols-1 gap-12 md:grid-cols-2 xl:grid-cols-4">
|
||||
{projectsData.map(
|
||||
({ img, title, description, tag, route, members }) => (
|
||||
<Card key={title} color="transparent" shadow={false}>
|
||||
<CardHeader
|
||||
floated={false}
|
||||
color="gray"
|
||||
className="mx-0 mt-0 mb-4 h-64 xl:h-40"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody className="py-0 px-1">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{tag}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
color="blue-gray"
|
||||
className="mt-1 mb-2"
|
||||
>
|
||||
<div className="flex flex-col gap-12">
|
||||
{platformSettingsData.map(({ title, options }) => (
|
||||
<div key={title}>
|
||||
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</CardBody>
|
||||
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1">
|
||||
<Link to={route}>
|
||||
<Button variant="outlined" size="sm">
|
||||
view project
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
{members.map(({ img, name }, key) => (
|
||||
<Tooltip key={name} content={name}>
|
||||
<Avatar
|
||||
src={img}
|
||||
alt={name}
|
||||
size="xs"
|
||||
variant="circular"
|
||||
className={`cursor-pointer border-2 border-white ${key === 0 ? "" : "-ml-2.5"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="flex flex-col gap-6">
|
||||
{options.map(({ checked, label }) => (
|
||||
<Switch
|
||||
key={label}
|
||||
id={label}
|
||||
label={label}
|
||||
defaultChecked={checked}
|
||||
labelProps={{
|
||||
className: "text-sm font-normal text-blue-gray-500",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
</CardBody>
|
||||
</Card></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Typography,
|
||||
Button,
|
||||
Spinner,
|
||||
Select,
|
||||
Option,
|
||||
Input,
|
||||
Chip
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||
|
||||
const _ = AVATAR_COLORS.join(" ");
|
||||
|
||||
const mapGroup = (item) => ({
|
||||
id: item?.id ?? null,
|
||||
name: item?.name || "",
|
||||
description: item?.description || "",
|
||||
avatarColor: item?.avatarColor || null,
|
||||
createdAt: item?.createdAt || null,
|
||||
updatedAt: item?.updatedAt || null,
|
||||
memberCount: Number.isFinite(Number(item?.memberCount)) ? Number(item.memberCount) : 0,
|
||||
members: Array.isArray(item?.members)
|
||||
? item.members
|
||||
.map((member) => ({
|
||||
id: member?.id ?? null,
|
||||
username: member?.username || ""
|
||||
}))
|
||||
.filter((member) => member.username)
|
||||
: []
|
||||
});
|
||||
|
||||
const buildInitialFormValues = (group) => {
|
||||
if (!group) {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
avatarColor: ""
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: group.name || "",
|
||||
description: group.description || "",
|
||||
avatarColor: group.avatarColor || ""
|
||||
};
|
||||
};
|
||||
|
||||
export function UserGroupDetail() {
|
||||
const { groupId } = useParams();
|
||||
const { showToast } = useToast();
|
||||
const { maintenance } = useMaintenance();
|
||||
|
||||
const [group, setGroup] = useState(null);
|
||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [savingGroup, setSavingGroup] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
|
||||
|
||||
const numericGroupId = useMemo(() => {
|
||||
const candidate = Number(groupId);
|
||||
return Number.isFinite(candidate) ? candidate : null;
|
||||
}, [groupId]);
|
||||
|
||||
const fetchGroupDetails = useCallback(async () => {
|
||||
if (!numericGroupId) {
|
||||
setError("Ungültige Gruppen-ID.");
|
||||
setGroup(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/groups/${numericGroupId}`);
|
||||
const item = mapGroup(response.data?.item);
|
||||
if (!item.id) {
|
||||
throw new Error("GROUP_NOT_FOUND");
|
||||
}
|
||||
setGroup(item);
|
||||
const initialValues = buildInitialFormValues(item);
|
||||
initialFormValuesRef.current = { ...initialValues };
|
||||
setFormValues(initialValues);
|
||||
setSaveError("");
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Gruppendetails konnten nicht geladen werden.";
|
||||
|
||||
if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
|
||||
} else if (serverError === "INVALID_GROUP_ID") {
|
||||
message = "Die angegebene Gruppen-ID ist ungültig.";
|
||||
} else if (err.response?.status === 404) {
|
||||
message = "Die angeforderte Benutzergruppe existiert nicht.";
|
||||
}
|
||||
|
||||
setGroup(null);
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Fehler beim Laden",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
}
|
||||
}, [numericGroupId, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroupDetails();
|
||||
}, [fetchGroupDetails]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!hasLoaded || !group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initial = initialFormValuesRef.current;
|
||||
if (!initial) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialName = initial.name || "";
|
||||
const currentName = formValues.name || "";
|
||||
|
||||
const initialDescription = initial.description || "";
|
||||
const currentDescription = formValues.description || "";
|
||||
|
||||
const initialAvatar = initial.avatarColor || "";
|
||||
const currentAvatar = formValues.avatarColor || "";
|
||||
|
||||
return (
|
||||
initialName !== currentName ||
|
||||
initialDescription !== currentDescription ||
|
||||
initialAvatar !== currentAvatar
|
||||
);
|
||||
}, [formValues, hasLoaded, group]);
|
||||
|
||||
const handleNameChange = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
name: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDescriptionChange = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
description: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAvatarColorChange = useCallback((value) => {
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
avatarColor: value || ""
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSaveGroup = useCallback(async () => {
|
||||
if (!group || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingGroup(true);
|
||||
setSaveError("");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
avatarColor: formValues.avatarColor
|
||||
};
|
||||
|
||||
if (!isSuperuserGroup) {
|
||||
payload.name = formValues.name;
|
||||
payload.description = formValues.description;
|
||||
}
|
||||
|
||||
const response = await axios.put(`/api/groups/${group.id}`, payload);
|
||||
const updated = mapGroup(response.data?.item || response.data?.group);
|
||||
setGroup(updated);
|
||||
const nextInitial = buildInitialFormValues(updated);
|
||||
initialFormValuesRef.current = { ...nextInitial };
|
||||
setFormValues(nextInitial);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Gruppe gespeichert",
|
||||
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||
});
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Die Gruppe konnte nicht gespeichert werden.";
|
||||
|
||||
if (serverError === "GROUP_NAME_REQUIRED") {
|
||||
message = "Bitte einen Gruppennamen angeben.";
|
||||
} else if (serverError === "GROUP_NAME_TAKEN") {
|
||||
message = "Der Gruppenname wird bereits verwendet.";
|
||||
} else if (serverError === "INVALID_AVATAR_COLOR") {
|
||||
message = "Bitte eine gültige Avatar-Farbe auswählen.";
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die Benutzergruppe wurde nicht gefunden.";
|
||||
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
||||
message = "Für die Superuser-Gruppe kann nur die Avatar-Farbe angepasst werden.";
|
||||
}
|
||||
|
||||
setSaveError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Speichern fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setSavingGroup(false);
|
||||
}
|
||||
}, [group, hasChanges, formValues, showToast, isSuperuserGroup]);
|
||||
|
||||
const avatarLabel = useMemo(() => {
|
||||
const source = (formValues.name || formValues.description || "").trim();
|
||||
if (!source) {
|
||||
return "?";
|
||||
}
|
||||
return source.charAt(0).toUpperCase();
|
||||
}, [formValues.name, formValues.description]);
|
||||
|
||||
const avatarColorClass = useMemo(() => {
|
||||
if (formValues.avatarColor) {
|
||||
return formValues.avatarColor;
|
||||
}
|
||||
return group?.avatarColor || "";
|
||||
}, [formValues.avatarColor, group]);
|
||||
|
||||
const selectDisabled = maintenanceActive || savingGroup || !group;
|
||||
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup;
|
||||
const selectValue = formValues.avatarColor || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-12 flex flex-col gap-12">
|
||||
{maintenanceActive && (
|
||||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Wartungsmodus aktiv – Änderungen sind deaktiviert.
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||
</div>
|
||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||
<CardBody className="p-4">
|
||||
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${avatarColorClass}`}
|
||||
aria-label={formValues.name || "Gruppenavatar"}
|
||||
>
|
||||
{avatarLabel}
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
{formValues.name || "–"}
|
||||
</Typography>
|
||||
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
|
||||
Gruppen-ID: {group?.id ?? "–"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
color="green"
|
||||
className="normal-case"
|
||||
onClick={handleSaveGroup}
|
||||
disabled={maintenanceActive || savingGroup}
|
||||
>
|
||||
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{loading && !group && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Gruppendaten werden geladen ...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{saveError && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||
Gruppendaten
|
||||
</Typography>
|
||||
{(maintenanceActive || isSuperuserGroup) && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{maintenanceActive
|
||||
? "Wartungsmodus aktiv – Änderungen sind deaktiviert."
|
||||
: "Systemgruppe – Name und Beschreibung sind geschützt."}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Gruppenname
|
||||
</Typography>
|
||||
<Input
|
||||
value={formValues.name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Gruppenname"
|
||||
disabled={inputDisabled}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Beschreibung
|
||||
</Typography>
|
||||
<Input
|
||||
value={formValues.description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="Beschreibung der Gruppe"
|
||||
disabled={inputDisabled}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Avatar-Farbe
|
||||
</Typography>
|
||||
<Select
|
||||
label="Avatar-Farbe auswählen"
|
||||
variant="outlined"
|
||||
value={selectValue}
|
||||
onChange={handleAvatarColorChange}
|
||||
disabled={selectDisabled}
|
||||
selected={(element) => {
|
||||
if (element?.props?.children) {
|
||||
return element.props.children;
|
||||
}
|
||||
if (!formValues.avatarColor) {
|
||||
return "Standardfarbe";
|
||||
}
|
||||
return formValues.avatarColor;
|
||||
}}
|
||||
>
|
||||
<Option value="">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded border border-blue-gray-100" />
|
||||
<span className="text-xs">Keine</span>
|
||||
</span>
|
||||
</Option>
|
||||
{AVATAR_COLORS.map((color) => (
|
||||
<Option key={color} value={color}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||||
<span className="text-xs">{color}</span>
|
||||
</span>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||
Mitglieder
|
||||
</Typography>
|
||||
{!group || group.members.length === 0 ? (
|
||||
<Typography className="text-sm text-stormGrey-500">
|
||||
Aktuell sind keine Benutzer dieser Gruppe zugeordnet.
|
||||
</Typography>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.members.map((member) => (
|
||||
<Chip
|
||||
key={`${group.id}-${member.id}-${member.username}`}
|
||||
value={member.username}
|
||||
size="sm"
|
||||
color="blue-gray"
|
||||
variant="ghost"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 text-xs text-stormGrey-400">
|
||||
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserGroupDetail;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -24,10 +25,12 @@ export function Usergroups() {
|
||||
const [newGroupDescription, setNewGroupDescription] = useState("");
|
||||
const [createGroupError, setCreateGroupError] = useState("");
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
const [deletingGroupId, setDeletingGroupId] = useState(null);
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { maintenance } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
page,
|
||||
@@ -54,6 +57,7 @@ export function Usergroups() {
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
avatarColor: item.avatarColor || null,
|
||||
memberCount: Number(item.memberCount) || 0,
|
||||
members: Array.isArray(item.members)
|
||||
? item.members.map((member) => ({
|
||||
@@ -233,8 +237,86 @@ export function Usergroups() {
|
||||
|
||||
const createGroupDisabled = maintenanceActive || creatingGroup;
|
||||
|
||||
const handleDeleteGroup = useCallback(async (group) => {
|
||||
if (maintenanceActive || !group?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||
if (isSuperuserGroup) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen nicht möglich",
|
||||
description: "Die Superuser-Gruppe kann nicht gelöscht werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const memberCount = Number(group.memberCount) || 0;
|
||||
if (memberCount > 0) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen nicht möglich",
|
||||
description: "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmation = window.confirm(`Benutzergruppe "${group.name}" wirklich löschen?`);
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericId = Number(group.id);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: "Ungültige Gruppen-ID."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingGroupId(numericId);
|
||||
try {
|
||||
await axios.delete(`/api/groups/${numericId}`);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Gruppe gelöscht",
|
||||
description: `Die Benutzergruppe "${group.name}" wurde entfernt.`
|
||||
});
|
||||
fetchGroups();
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Die Benutzergruppe konnte nicht gelöscht werden.";
|
||||
|
||||
if (serverError === "INVALID_GROUP_ID") {
|
||||
message = "Die Gruppen-ID ist ungültig.";
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die Benutzergruppe wurde bereits entfernt.";
|
||||
} else if (serverError === "GROUP_HAS_MEMBERS") {
|
||||
message = "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst.";
|
||||
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
||||
message = "Die Superuser-Gruppe kann nicht gelöscht werden.";
|
||||
}
|
||||
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setDeletingGroupId(null);
|
||||
}
|
||||
}, [maintenanceActive, showToast, fetchGroups]);
|
||||
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||
{maintenanceActive && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Wartungsmodus aktiv – Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
|
||||
</div>
|
||||
)}
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
@@ -245,13 +327,6 @@ export function Usergroups() {
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
|
||||
{maintenanceActive && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Wartungsmodus aktiv – Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
Neue Benutzergruppe anlegen
|
||||
@@ -335,7 +410,6 @@ export function Usergroups() {
|
||||
<th className="px-6 py-4 font-semibold">Gruppenname</th>
|
||||
<th className="px-6 py-4 font-semibold">Beschreibung</th>
|
||||
<th className="px-6 py-4 font-semibold">Mitglieder</th>
|
||||
<th className="px-6 py-4 font-semibold">Anzahl</th>
|
||||
<th className="px-6 py-4 font-semibold">Erstellt am</th>
|
||||
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
|
||||
<th className="px-6 py-4 font-semibold">Aktionen</th>
|
||||
@@ -358,6 +432,7 @@ export function Usergroups() {
|
||||
) : (
|
||||
paginatedGroups.map((group, index) => {
|
||||
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||
return (
|
||||
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||
<td className="px-6 py-4">
|
||||
@@ -375,29 +450,10 @@ export function Usergroups() {
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{group.members.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.members.map((member) => (
|
||||
<Chip
|
||||
key={`${group.id}-${member.id}-${member.username}`}
|
||||
value={member.username}
|
||||
size="sm"
|
||||
color="blue-gray"
|
||||
variant="ghost"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-stormGrey-400">Keine Mitglieder</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Chip
|
||||
value={group.memberCount.toLocaleString("de-DE")}
|
||||
size="sm"
|
||||
color={group.memberCount > 0 ? "green" : "blue-gray"}
|
||||
variant="ghost"
|
||||
/>
|
||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{group.memberCount.toLocaleString("de-DE")}
|
||||
</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">
|
||||
@@ -410,9 +466,34 @@ export function Usergroups() {
|
||||
</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">
|
||||
Aktionen
|
||||
</Typography>
|
||||
<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/usergroups/${group.id}`)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
disabled={
|
||||
maintenanceActive ||
|
||||
deletingGroupId === group.id ||
|
||||
Number(group.memberCount) > 0 ||
|
||||
isSuperuserGroup
|
||||
}
|
||||
>
|
||||
{isSuperuserGroup
|
||||
? ""
|
||||
: deletingGroupId === group.id
|
||||
? "Löscht ..."
|
||||
: "Löschen"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ 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 { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||
|
||||
const normalizeUserGroups = (rawGroups) => {
|
||||
if (!Array.isArray(rawGroups)) {
|
||||
@@ -55,9 +54,10 @@ export function Users() {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newGroupId, setNewGroupId] = useState("");
|
||||
const [newAvatarColor, setNewAvatarColor] = 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 } = useMaintenance();
|
||||
@@ -123,6 +123,7 @@ export function Users() {
|
||||
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) {
|
||||
@@ -243,10 +244,140 @@ export function Users() {
|
||||
setNewEmail("");
|
||||
setNewPassword("");
|
||||
setNewGroupId("");
|
||||
setNewAvatarColor("");
|
||||
setCreateError("");
|
||||
}, []);
|
||||
|
||||
const handleDeleteUser = useCallback(async (user) => {
|
||||
if (maintenanceActive || !user?.id) {
|
||||
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);
|
||||
}
|
||||
}, [maintenanceActive, showToast, fetchUsers]);
|
||||
|
||||
const handleToggleUserStatus = useCallback(async (user) => {
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
if (maintenanceActive) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Aktion nicht möglich",
|
||||
description: "Im Wartungsmodus können keine Statusänderungen durchgeführt werden."
|
||||
});
|
||||
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);
|
||||
}
|
||||
}, [maintenanceActive, showToast, fetchUsers]);
|
||||
|
||||
const handleCreateUser = useCallback(async () => {
|
||||
if (maintenanceActive) {
|
||||
return;
|
||||
@@ -277,9 +408,6 @@ export function Users() {
|
||||
if (trimmedEmail) {
|
||||
payload.email = trimmedEmail;
|
||||
}
|
||||
if (newAvatarColor) {
|
||||
payload.avatarColor = newAvatarColor;
|
||||
}
|
||||
const response = await axios.post("/api/users", payload);
|
||||
resetNewUserForm();
|
||||
fetchUsers();
|
||||
@@ -330,16 +458,14 @@ export function Users() {
|
||||
newEmail,
|
||||
newPassword,
|
||||
newGroupId,
|
||||
newAvatarColor,
|
||||
showToast,
|
||||
resetNewUserForm,
|
||||
fetchUsers
|
||||
]);
|
||||
|
||||
const createDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0;
|
||||
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0;
|
||||
const avatarSelectDisabled = maintenanceActive || creatingUser;
|
||||
|
||||
const hasSelectableGroups = availableGroups.length > 0;
|
||||
const createDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||
const renderSelectedGroup = useCallback(
|
||||
(element) => {
|
||||
if (element && element.props && element.props.children) {
|
||||
@@ -354,26 +480,13 @@ export function Users() {
|
||||
[availableGroups, newGroupId]
|
||||
);
|
||||
|
||||
const renderSelectedAvatar = useCallback(
|
||||
(element) => {
|
||||
if (element && element.props && element.props.children) {
|
||||
return element.props.children;
|
||||
}
|
||||
if (!newAvatarColor) {
|
||||
return "Automatisch (zufällig)";
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${newAvatarColor}`} />
|
||||
<span className="text-xs">{newAvatarColor}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[newAvatarColor]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||
{maintenanceActive && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Wartungsmodus aktiv – Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
@@ -384,177 +497,111 @@ export function Users() {
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{maintenanceActive && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Wartungsmodus aktiv – Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<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>
|
||||
<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={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail (optional)"
|
||||
value={newEmail}
|
||||
onChange={(event) => setNewEmail(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="Passwort"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
disabled={maintenanceActive || 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>
|
||||
<Select
|
||||
label="Avatar-Farbe"
|
||||
variant="outlined"
|
||||
value={newAvatarColor}
|
||||
onChange={setNewAvatarColor}
|
||||
disabled={avatarSelectDisabled}
|
||||
selected={renderSelectedAvatar}
|
||||
>
|
||||
<Option value="">Automatisch (zufällig)</Option>
|
||||
{AVATAR_COLORS.map((color) => (
|
||||
<Option key={color} value={color}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||||
<span className="text-xs">{color}</span>
|
||||
</span>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-stormGrey-500">
|
||||
{groupsLoading
|
||||
? "Benutzergruppen werden geladen ..."
|
||||
: availableGroups.length === 0
|
||||
? "Noch keine Benutzergruppen vorhanden. Bitte legen Sie zuerst eine globale Rolle an."
|
||||
: ""}
|
||||
</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}>
|
||||
Formular zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
|
||||
<div className="flex flex-wrap gap-2"> {/* <div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-blue-gray-400">
|
||||
Einträge pro Seite
|
||||
</label>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={handlePerPageChange}
|
||||
className="w-full rounded-lg border border-blue-gray-100 px-3 py-2 text-sm text-blue-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:w-40"
|
||||
>
|
||||
{perPageOptions.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="blue"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{loading ? "Lädt ..." : "Aktualisieren"}
|
||||
</Button>
|
||||
</div> */}
|
||||
{/* <div className="w-full md:max-w-md">
|
||||
<Input
|
||||
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
disabled={loading && !users.length}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div> */}
|
||||
{/* {searchQuery && (
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={handleClearSearch}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Suche zurücksetzen
|
||||
</Button>
|
||||
)} */}
|
||||
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||
<div className="md:flex-1">
|
||||
{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="Suchen nach Name, E-Mail oder Gruppe"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
disabled={loading && !users.length}
|
||||
label="Benutzername"
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail (optional)"
|
||||
value={newEmail}
|
||||
onChange={(event) => setNewEmail(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="Passwort"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div>
|
||||
<div className="md:mt-0 mt-8 md:flex-1">
|
||||
<Select
|
||||
variant="static"
|
||||
label="Einträge pro Seite"
|
||||
onChange={noop}
|
||||
value={perPage}
|
||||
label="Globale Rolle"
|
||||
variant="outlined"
|
||||
value={newGroupId}
|
||||
onChange={setNewGroupId}
|
||||
disabled={groupSelectDisabled}
|
||||
selected={renderSelectedGroup}
|
||||
>
|
||||
{perPageOptions.map(({ value, label }) => (
|
||||
<Option key={value} value={value}>
|
||||
{label}
|
||||
<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}>
|
||||
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>
|
||||
@@ -594,6 +641,9 @@ export function Users() {
|
||||
) : (
|
||||
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">
|
||||
@@ -624,12 +674,20 @@ export function Users() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Chip
|
||||
value={user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
size="sm"
|
||||
color={user.isActive ? "green" : "red"}
|
||||
variant="ghost"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onClick={() => handleToggleUserStatus(user)}
|
||||
disabled={maintenanceActive || 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>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
@@ -642,14 +700,29 @@ export function Users() {
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
color="blue-gray"
|
||||
onClick={() => navigate(`/dashboard/users/${user.id}`)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
<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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={maintenanceActive || deletingUserId === user.id || isSuperuserUser}
|
||||
>
|
||||
{isSuperuserUser
|
||||
? ""
|
||||
: deletingUserId === user.id
|
||||
? "Löscht ..."
|
||||
: "Löschen"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user