Update
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -9,6 +9,7 @@ import {
|
||||
Footer,
|
||||
} from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
|
||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||
|
||||
export function Dashboard() {
|
||||
@@ -154,6 +155,7 @@ export function Dashboard() {
|
||||
<Route exact path={path} element={element} />
|
||||
))
|
||||
)}
|
||||
<Route path="users/:userId" element={<UserDetails />} />
|
||||
</Routes>
|
||||
<div className="text-blue-gray-600">
|
||||
<Footer />
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Alert,
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import pattern from "@/assets/images/pattern.png";
|
||||
|
||||
export function SignIn() {
|
||||
const navigate = useNavigate();
|
||||
@@ -195,7 +196,7 @@ export function SignIn() {
|
||||
</form>
|
||||
</div>
|
||||
<div className="w-2/5 h-full hidden lg:block">
|
||||
<img src="/img/pattern.png" className="h-full w-full object-cover rounded-3xl" />
|
||||
<img src={pattern} className="h-full w-full object-cover rounded-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from "@/pages/dashboard/stacks";
|
||||
export * from "@/pages/dashboard/maintenance";
|
||||
export * from "@/pages/dashboard/logs";
|
||||
export * from "@/pages/dashboard/users";
|
||||
export * from "@/pages/dashboard/usergroups";
|
||||
export * from "@/pages/dashboard/usergroups";
|
||||
export * from "@/pages/dashboard/userDetails";
|
||||
|
||||
@@ -148,20 +148,20 @@ export function Logs() {
|
||||
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
||||
const [refreshSignal, setRefreshSignal] = useState(0);
|
||||
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
maintenance: maintenanceMeta,
|
||||
update: updateState,
|
||||
script: scriptConfig,
|
||||
ssh: sshConfig,
|
||||
} = useMaintenance();
|
||||
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
maintenance: maintenanceMeta,
|
||||
update: updateState,
|
||||
script: scriptConfig,
|
||||
ssh: sshConfig,
|
||||
} = useMaintenance();
|
||||
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
|
||||
|
||||
const {
|
||||
page,
|
||||
@@ -620,8 +620,8 @@ export function Logs() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const toggleOpen = () => setOpen((cur) => !cur);
|
||||
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="mt-12 mb-8">
|
||||
{(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" : ""}.
|
||||
@@ -635,14 +635,14 @@ export function Logs() {
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader variant="gradient" color="gray" className="p-4 pt-2 pb-2">
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography variant="h6" color="white">
|
||||
<button
|
||||
onClick={handleToggleFilters}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>Filter und Optionen</span>
|
||||
<span>Logs</span>
|
||||
{activeFilterCount > 0 && (
|
||||
|
||||
<span className="rounded-full bg-blue-gray-500/80 px-2 py-0.5 text-xs text-white">
|
||||
@@ -651,14 +651,14 @@ export function Logs() {
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{filtersOpen ? "Ausblenden" : "Anzeigen"}
|
||||
{filtersOpen ? "Filter ausblenden" : "Filter anzeigen"}
|
||||
</span>
|
||||
</button>
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CardBody className="pt-0">
|
||||
{filtersOpen && (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -945,118 +945,106 @@ export function Logs() {
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="white"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Logs</span>
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
|
||||
<table className="w-full min-w-[640px] table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
|
||||
<th
|
||||
key={el}
|
||||
className="border-b border-stormGrey-50 py-3 px-5 text-left"
|
||||
>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="text-[11px] font-bold uppercase text-stormGrey-400"
|
||||
>
|
||||
<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">
|
||||
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
|
||||
<th key={el} className="px-6 py-4 font-semibold">
|
||||
{el}
|
||||
</Typography>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => {
|
||||
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
|
||||
const className = "py-3 px-5";
|
||||
const stackDisplayName = log.stackName || "Unbekannt";
|
||||
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
|
||||
const redeployTypeLabel = log.redeployType
|
||||
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
|
||||
: '---';
|
||||
return (
|
||||
<tr key={log.id}>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{stackDisplayName}</span>
|
||||
<Typography
|
||||
variant="small"
|
||||
>
|
||||
{showStackId && (
|
||||
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
>
|
||||
{redeployTypeLabel}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
className={`font-semibold ${statusClass}`}
|
||||
>
|
||||
{log.status}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
>
|
||||
{log.message || "-"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
>
|
||||
{log.endpoint ?? "-"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
disabled={actionLoading}
|
||||
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60">
|
||||
Löschen
|
||||
</button>
|
||||
</Typography>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-stormGrey-400">
|
||||
Logs werden geladen ...
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-stormGrey-400">
|
||||
Keine Logs gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log, index) => {
|
||||
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
|
||||
const stackDisplayName = log.stackName || "Unbekannt";
|
||||
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
|
||||
const redeployTypeLabel = log.redeployType
|
||||
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
|
||||
: '---';
|
||||
const rowClass = index === logs.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||
return (
|
||||
<tr key={log.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||
<td className="px-6 py-4">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{stackDisplayName}</span>
|
||||
<Typography variant="small">
|
||||
{showStackId && (
|
||||
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small">
|
||||
{redeployTypeLabel}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography
|
||||
variant="small"
|
||||
className={`font-semibold ${statusClass}`}
|
||||
>
|
||||
{log.status}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small">
|
||||
{log.message || "-"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small">
|
||||
{log.endpoint ?? "-"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small">
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
disabled={actionLoading}
|
||||
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60">
|
||||
Löschen
|
||||
</button>
|
||||
</Typography>
|
||||
</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 && logs.length === 0} />
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<PaginationControls disabled={actionLoading} />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Button,
|
||||
Spinner,
|
||||
CardFooter,
|
||||
Tabs,
|
||||
TabsHeader,
|
||||
Tab,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
Checkbox
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
HomeIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
Cog6ToothIcon,
|
||||
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 { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
|
||||
const DEFAULT_AVATAR = { background: "bg-blue-gray-500", text: "text-white" };
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'bg-arcticBlue-600',
|
||||
'bg-copperRust-500',
|
||||
'bg-sunsetCoral-600',
|
||||
'bg-mintTea-400',
|
||||
'bg-lavenderSmoke-500',
|
||||
'bg-emeraldMist-500',
|
||||
'bg-roseQuartz-500',
|
||||
'bg-auroraTeal-500',
|
||||
'bg-citrusPunch-500',
|
||||
'bg-mossGreen-400'
|
||||
];
|
||||
const _ = AVATAR_COLORS.join(" ");
|
||||
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const mapUser = (item) => ({
|
||||
id: item?.id ?? null,
|
||||
username: item?.username || "",
|
||||
email: item?.email || "",
|
||||
isActive: Boolean(item?.isActive),
|
||||
avatarColor: item?.avatarColor || null,
|
||||
lastLogin: item?.lastLogin || null,
|
||||
createdAt: item?.createdAt || null,
|
||||
updatedAt: item?.updatedAt || null,
|
||||
groups: normalizeUserGroups(item?.groups)
|
||||
});
|
||||
|
||||
export function UserDetails() {
|
||||
const { userId } = useParams();
|
||||
const { showToast } = useToast();
|
||||
const { maintenance } = useMaintenance();
|
||||
|
||||
const [user, setUser] = useState(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 [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
||||
const [savingGroups, setSavingGroups] = useState(false);
|
||||
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
|
||||
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);
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${numericUserId}`);
|
||||
const item = mapUser(response.data?.item);
|
||||
if (!item.id) {
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
setUser(item);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Benutzerdetails konnten nicht geladen werden.";
|
||||
|
||||
if (serverError === "USER_NOT_FOUND") {
|
||||
message = "Der angeforderte Benutzer wurde nicht gefunden.";
|
||||
} else if (serverError === "INVALID_USER_ID") {
|
||||
message = "Die angegebene Benutzer-ID ist ungültig.";
|
||||
} else if (err.response?.status === 404) {
|
||||
message = "Der angeforderte Benutzer existiert nicht.";
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Fehler beim Laden",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
}
|
||||
}, [numericUserId, showToast]);
|
||||
|
||||
const fetchAvailableGroups = 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 || "",
|
||||
description: item.description || "",
|
||||
memberCount: Number.isFinite(Number(item.memberCount)) ? Number(item.memberCount) : 0
|
||||
}))
|
||||
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
|
||||
.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(() => {
|
||||
fetchUserDetails();
|
||||
}, [fetchUserDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableGroups();
|
||||
}, [fetchAvailableGroups]);
|
||||
|
||||
const originalGroupIds = useMemo(() => {
|
||||
if (!user || !Array.isArray(user.groups)) {
|
||||
return [];
|
||||
}
|
||||
return user.groups
|
||||
.map((group) => Number(group.id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedGroupIds([...originalGroupIds]);
|
||||
}, [originalGroupIds]);
|
||||
|
||||
const hasGroupChanges = useMemo(() => {
|
||||
const normalize = (ids) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
ids
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0)
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
const next = normalize(selectedGroupIds);
|
||||
const baseline = normalize(originalGroupIds);
|
||||
|
||||
if (next.length !== baseline.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return next.some((value, index) => value !== baseline[index]);
|
||||
}, [selectedGroupIds, originalGroupIds]);
|
||||
|
||||
const handleToggleGroup = useCallback((groupId) => {
|
||||
const numeric = Number(groupId);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return;
|
||||
}
|
||||
setSelectedGroupIds((prev) => {
|
||||
if (prev.includes(numeric)) {
|
||||
return prev.filter((id) => id !== numeric);
|
||||
}
|
||||
return [...prev, numeric];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetGroups = useCallback(() => {
|
||||
setSelectedGroupIds([...originalGroupIds]);
|
||||
}, [originalGroupIds]);
|
||||
|
||||
const handleSaveGroups = useCallback(async () => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadGroups = Array.from(
|
||||
new Set(
|
||||
selectedGroupIds
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0)
|
||||
)
|
||||
);
|
||||
|
||||
setSavingGroups(true);
|
||||
setGroupsError("");
|
||||
try {
|
||||
const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups });
|
||||
const updatedUser = mapUser(response.data?.item || response.data?.user);
|
||||
setUser(updatedUser);
|
||||
setSelectedGroupIds(
|
||||
Array.isArray(updatedUser.groups)
|
||||
? updatedUser.groups
|
||||
.map((group) => Number(group.id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0)
|
||||
: []
|
||||
);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Benutzergruppen aktualisiert",
|
||||
description: "Die Gruppenzuordnung wurde gespeichert."
|
||||
});
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = err.message || "Benutzergruppen konnten 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.";
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Mindestens eine ausgewählte Gruppe existiert nicht mehr.";
|
||||
} else if (typeof serverError === "string" && serverError.length > 0) {
|
||||
message = serverError;
|
||||
}
|
||||
setGroupsError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Benutzergruppen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setSavingGroups(false);
|
||||
}
|
||||
}, [user, selectedGroupIds, showToast]);
|
||||
|
||||
const avatarLabel = useMemo(() => {
|
||||
const source = (user?.username || user?.email || "").trim();
|
||||
if (!source) {
|
||||
return "?";
|
||||
}
|
||||
return source.charAt(0).toUpperCase();
|
||||
}, [user]);
|
||||
|
||||
|
||||
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>
|
||||
{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>
|
||||
)}
|
||||
<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 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">
|
||||
Benutzergruppen
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
color="blue-gray"
|
||||
className="normal-case"
|
||||
onClick={handleResetGroups}
|
||||
disabled={!hasGroupChanges || savingGroups || !user}
|
||||
>
|
||||
Änderungen verwerfen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="green"
|
||||
className="normal-case"
|
||||
onClick={handleSaveGroups}
|
||||
disabled={maintenanceActive || savingGroups || !hasGroupChanges || !user}
|
||||
>
|
||||
{savingGroups ? "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">
|
||||
{availableGroups.map((group) => {
|
||||
const checked = selectedGroupIds.includes(group.id);
|
||||
return (
|
||||
<label
|
||||
key={group.id}
|
||||
className={`flex items-start justify-between gap-3 rounded-lg border px-4 py-3 transition ${
|
||||
checked ? "border-auroraTeal-500 bg-auroraTeal-500/10" : "border-blue-gray-100 bg-white"
|
||||
} ${maintenanceActive ? "opacity-70" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
ripple={false}
|
||||
className="mt-1"
|
||||
checked={checked}
|
||||
onChange={() => handleToggleGroup(group.id)}
|
||||
disabled={maintenanceActive || savingGroups || !user}
|
||||
/>
|
||||
<div>
|
||||
<Typography variant="small" className="font-semibold text-blue-gray-900">
|
||||
{group.name}
|
||||
</Typography>
|
||||
{group.description && (
|
||||
<Typography variant="small" className="text-xs text-stormGrey-500">
|
||||
{group.description}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Typography variant="small" className="text-xs font-medium text-stormGrey-500">
|
||||
Mitglieder: {group.memberCount}
|
||||
</Typography>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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 UserDetails;
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Input
|
||||
Select,
|
||||
Option,
|
||||
Input,
|
||||
useSelect
|
||||
} from "@material-tailwind/react";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
@@ -35,6 +38,7 @@ export function Usergroups() {
|
||||
} = usePage();
|
||||
|
||||
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||
const noop = useCallback(() => { }, []);
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -50,9 +54,9 @@ export function Usergroups() {
|
||||
memberCount: Number(item.memberCount) || 0,
|
||||
members: Array.isArray(item.members)
|
||||
? item.members.map((member) => ({
|
||||
id: member.id,
|
||||
username: member.username || ""
|
||||
})).filter((member) => member.username)
|
||||
id: member.id,
|
||||
username: member.username || ""
|
||||
})).filter((member) => member.username)
|
||||
: [],
|
||||
createdAt: item.createdAt || null,
|
||||
updatedAt: item.updatedAt || null
|
||||
@@ -170,77 +174,54 @@ export function Usergroups() {
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
color="transparent"
|
||||
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<Typography variant="h4" color="blue-gray">
|
||||
Benutzergruppen
|
||||
</Typography>
|
||||
<Typography variant="small" color="gray" className="font-normal">
|
||||
Übersicht aller Gruppen inklusive Mitgliederliste.
|
||||
</Typography>
|
||||
</div>
|
||||
<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>
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="white"
|
||||
>
|
||||
<span>Benutzergruppen</span>
|
||||
</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-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="w-full md:max-w-md">
|
||||
<Input
|
||||
label="Suchen nach Gruppenname, Beschreibung oder Mitglied"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
disabled={loading && !groups.length}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="md:flex-1">
|
||||
<Input
|
||||
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
disabled={loading && !groups.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>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={handleClearSearch}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Suche zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
@@ -250,25 +231,27 @@ export function Usergroups() {
|
||||
<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-blue-gray-400">
|
||||
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
|
||||
<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>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && groups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
|
||||
Gruppen werden geladen ...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedGroups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
|
||||
Keine Gruppen gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -276,14 +259,20 @@ export function Usergroups() {
|
||||
paginatedGroups.map((group, index) => {
|
||||
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||
return (
|
||||
<tr key={group.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
|
||||
<td className="px-6 py-4 font-medium text-blue-gray-900">{group.name || "–"}</td>
|
||||
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||
<td className="px-6 py-4">
|
||||
{group.description ? (
|
||||
group.description
|
||||
) : (
|
||||
<span className="text-blue-gray-400">–</span>
|
||||
)}
|
||||
<Typography variant="small" className="font-medium text-stormGrey-900">
|
||||
{group.name || "–"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small">
|
||||
{group.description ? (
|
||||
group.description
|
||||
) : (
|
||||
<span className="text-stormGrey-400">–</span>
|
||||
)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{group.members.length > 0 ? (
|
||||
@@ -299,7 +288,7 @@ export function Usergroups() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-blue-gray-400">Keine Mitglieder</span>
|
||||
<span className="text-stormGrey-400">Keine Mitglieder</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -310,8 +299,21 @@ export function Usergroups() {
|
||||
variant="ghost"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">{formatTimestamp(group.createdAt)}</td>
|
||||
<td className="px-6 py-4">{formatTimestamp(group.updatedAt)}</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(group.createdAt)}
|
||||
</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(group.updatedAt)}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -7,12 +7,41 @@ import {
|
||||
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";
|
||||
|
||||
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);
|
||||
@@ -22,6 +51,8 @@ export function Users() {
|
||||
const { showToast } = useToast();
|
||||
const { maintenance } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const navigate = useNavigate();
|
||||
const noop = useCallback(() => { }, []);
|
||||
|
||||
const {
|
||||
page,
|
||||
@@ -48,10 +79,11 @@ export function Users() {
|
||||
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: Array.isArray(item.groups) ? item.groups : []
|
||||
groups: normalizeUserGroups(item.groups)
|
||||
}))
|
||||
.sort((a, b) => a.username.localeCompare(b.username, "de-DE"));
|
||||
setUsers(sorted);
|
||||
@@ -99,7 +131,7 @@ export function Users() {
|
||||
const haystacks = [
|
||||
user.username,
|
||||
user.email,
|
||||
...user.groups
|
||||
...user.groups.map((group) => group.name)
|
||||
].map((value) => String(value || "").toLowerCase());
|
||||
return haystacks.some((value) => value.includes(query));
|
||||
});
|
||||
@@ -165,48 +197,14 @@ export function Users() {
|
||||
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
color="transparent"
|
||||
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<Typography variant="h4" color="blue-gray">
|
||||
Benutzerverwaltung
|
||||
</Typography>
|
||||
<Typography variant="small" color="gray" className="font-normal">
|
||||
Übersicht aller im System angelegten Benutzer.
|
||||
</Typography>
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
{maintenanceActive && (
|
||||
@@ -215,8 +213,36 @@ export function Users() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="w-full md:max-w-md">
|
||||
<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}
|
||||
@@ -224,8 +250,8 @@ export function Users() {
|
||||
disabled={loading && !users.length}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
</div> */}
|
||||
{/* {searchQuery && (
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
@@ -234,9 +260,36 @@ export function Users() {
|
||||
>
|
||||
Suche zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
|
||||
<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>
|
||||
</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}
|
||||
@@ -246,25 +299,27 @@ export function Users() {
|
||||
<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-blue-gray-400">
|
||||
<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={6} className="px-6 py-8 text-center text-blue-gray-400">
|
||||
<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={6} className="px-6 py-8 text-center text-blue-gray-400">
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
|
||||
Keine Benutzer gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -272,18 +327,32 @@ export function Users() {
|
||||
paginatedUsers.map((user, index) => {
|
||||
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||
return (
|
||||
<tr key={user.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
|
||||
<td className="px-6 py-4 font-medium text-blue-gray-900">{user.username || "–"}</td>
|
||||
<td className="px-6 py-4">{user.email || "–"}</td>
|
||||
<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} value={group} size="sm" color="blue-gray" variant="ghost" />
|
||||
<Chip
|
||||
key={group.id ?? group.name}
|
||||
value={group.name}
|
||||
size="sm"
|
||||
color="blue-gray"
|
||||
variant="ghost"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-blue-gray-400">–</span>
|
||||
<span className="text-stormGrey-400">–</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -294,8 +363,26 @@ export function Users() {
|
||||
variant="ghost"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">{formatTimestamp(user.lastLogin)}</td>
|
||||
<td className="px-6 py-4">{formatTimestamp(user.createdAt)}</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">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
color="blue-gray"
|
||||
onClick={() => navigate(`/dashboard/users/${user.id}`)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
@@ -306,9 +393,7 @@ export function Users() {
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Typography variant="small" color="gray">
|
||||
{!loading && users.length === 0
|
||||
? "Noch keine Benutzer angelegt."
|
||||
: `${filteredCount.toLocaleString("de-DE")} Benutzer gefunden`}
|
||||
{""}
|
||||
</Typography>
|
||||
<PaginationControls disabled={loading && users.length === 0} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user