User/Usergroup Pages first draft
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
const selectGroupsWithMembers = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.name,
|
||||||
|
g.description,
|
||||||
|
g.created_at,
|
||||||
|
g.updated_at,
|
||||||
|
COUNT(DISTINCT u.id) AS member_count,
|
||||||
|
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
|
||||||
|
FROM user_groups g
|
||||||
|
LEFT JOIN user_group_memberships m ON m.group_id = g.id
|
||||||
|
LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.name COLLATE NOCASE
|
||||||
|
`);
|
||||||
|
|
||||||
|
const parseMembers = (rawPairs) => {
|
||||||
|
if (!rawPairs) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawPairs
|
||||||
|
.split('|||')
|
||||||
|
.map((entry) => {
|
||||||
|
const [idPart, usernamePart] = entry.split('::');
|
||||||
|
const numericId = Number(idPart);
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(numericId) ? numericId : idPart,
|
||||||
|
username: (usernamePart || '').trim()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((member) => member.username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeGroup = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description || '',
|
||||||
|
createdAt: row.created_at || null,
|
||||||
|
updatedAt: row.updated_at || null,
|
||||||
|
memberCount: Number(row.member_count) || 0,
|
||||||
|
members: parseMembers(row.member_pairs)
|
||||||
|
});
|
||||||
|
|
||||||
|
export function listGroups() {
|
||||||
|
const rows = selectGroupsWithMembers.all();
|
||||||
|
return rows.map(sanitizeGroup);
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
removeServer,
|
removeServer,
|
||||||
setServerApiKey
|
setServerApiKey
|
||||||
} from './setup/index.js';
|
} from './setup/index.js';
|
||||||
|
import { listUsers } from './users/index.js';
|
||||||
|
import { listGroups } from './groups/index.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -1671,6 +1673,26 @@ app.get('/api/auth/session', (req, res) => {
|
|||||||
res.json({ user: sanitizeUser(user) });
|
res.json({ user: sanitizeUser(user) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/users', (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = listUsers();
|
||||||
|
res.json({ items: users, total: users.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ [Users] Abruf der Benutzerliste fehlgeschlagen:', error);
|
||||||
|
res.status(500).json({ error: 'USERS_FETCH_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/groups', (req, res) => {
|
||||||
|
try {
|
||||||
|
const groups = listGroups();
|
||||||
|
res.json({ items: groups, total: groups.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ [Groups] Abruf der Benutzergruppenliste fehlgeschlagen:', error);
|
||||||
|
res.status(500).json({ error: 'GROUPS_FETCH_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Superuser Setup
|
// Superuser Setup
|
||||||
app.get('/api/auth/superuser/status', (req, res) => {
|
app.get('/api/auth/superuser/status', (req, res) => {
|
||||||
const exists = hasSuperuser();
|
const exists = hasSuperuser();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,8 +32,8 @@
|
|||||||
<!-- Nepcha Analytics (nepcha.com) -->
|
<!-- Nepcha Analytics (nepcha.com) -->
|
||||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||||
<script type="module" crossorigin src="/assets/index-999a8a11.js"></script>
|
<script type="module" crossorigin src="/assets/index-af8f32f4.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/index-4350b70a.css">
|
<link rel="stylesheet" href="/assets/index-58708f0f.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
const selectUsersWithGroups = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.email,
|
||||||
|
u.is_active,
|
||||||
|
u.last_login,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at,
|
||||||
|
GROUP_CONCAT(g.name, '|||') AS group_names
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||||
|
LEFT JOIN user_groups g ON g.id = m.group_id
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.username COLLATE NOCASE
|
||||||
|
`);
|
||||||
|
|
||||||
|
const normalizeGroupNames = (rawNames) => {
|
||||||
|
if (!rawNames) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return rawNames
|
||||||
|
.split('|||')
|
||||||
|
.map((name) => String(name || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeUserRecord = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
email: row.email,
|
||||||
|
isActive: Boolean(row.is_active),
|
||||||
|
lastLogin: row.last_login || null,
|
||||||
|
createdAt: row.created_at || null,
|
||||||
|
updatedAt: row.updated_at || null,
|
||||||
|
groups: normalizeGroupNames(row.group_names)
|
||||||
|
});
|
||||||
|
|
||||||
|
export function listUsers() {
|
||||||
|
const rows = selectUsersWithGroups.all();
|
||||||
|
return rows.map(sanitizeUserRecord);
|
||||||
|
}
|
||||||
@@ -5,25 +5,333 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardBody,
|
CardBody,
|
||||||
Typography,
|
Typography,
|
||||||
Avatar,
|
|
||||||
Chip,
|
Chip,
|
||||||
Tooltip,
|
|
||||||
Progress,
|
|
||||||
Collapse,
|
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
Input
|
||||||
Select,
|
|
||||||
Option,
|
|
||||||
Input,
|
|
||||||
useSelect
|
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
|
||||||
export function Usergroups() {
|
export function Usergroups() {
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { maintenance } = useMaintenance();
|
||||||
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
perPageOptions,
|
||||||
|
perPageIsAll,
|
||||||
|
handlePerPageChange,
|
||||||
|
setPage,
|
||||||
|
setTotals,
|
||||||
|
resetPagination
|
||||||
|
} = usePage();
|
||||||
|
|
||||||
|
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||||
|
|
||||||
|
const fetchGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/groups");
|
||||||
|
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const normalized = items
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || "",
|
||||||
|
description: item.description || "",
|
||||||
|
memberCount: Number(item.memberCount) || 0,
|
||||||
|
members: Array.isArray(item.members)
|
||||||
|
? item.members.map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
username: member.username || ""
|
||||||
|
})).filter((member) => member.username)
|
||||||
|
: [],
|
||||||
|
createdAt: item.createdAt || null,
|
||||||
|
updatedAt: item.updatedAt || null
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||||
|
setGroups(normalized);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
|
||||||
|
setError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzergruppen konnten nicht geladen werden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGroups();
|
||||||
|
}, [fetchGroups]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchGroups();
|
||||||
|
}, [fetchGroups]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((event) => {
|
||||||
|
setSearchQuery(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [searchQuery, page, setPage]);
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
return groups.filter((group) => {
|
||||||
|
const haystacks = [
|
||||||
|
group.name,
|
||||||
|
group.description,
|
||||||
|
...group.members.map((member) => member.username)
|
||||||
|
].map((value) => String(value || "").toLowerCase());
|
||||||
|
return haystacks.some((value) => value.includes(query));
|
||||||
|
});
|
||||||
|
}, [groups, searchQuery]);
|
||||||
|
|
||||||
|
const perPageNumber = useMemo(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}, [perPage, perPageIsAll]);
|
||||||
|
|
||||||
|
const paginatedGroups = useMemo(() => {
|
||||||
|
if (perPageIsAll || !perPageNumber) {
|
||||||
|
return filteredGroups;
|
||||||
|
}
|
||||||
|
const startIndex = (page - 1) * perPageNumber;
|
||||||
|
return filteredGroups.slice(startIndex, startIndex + perPageNumber);
|
||||||
|
}, [filteredGroups, page, perPageIsAll, perPageNumber]);
|
||||||
|
|
||||||
|
const filteredCount = filteredGroups.length;
|
||||||
|
const visibleCount = paginatedGroups.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
|
||||||
|
if (page > maxPages) {
|
||||||
|
setPage(maxPages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotals(filteredCount, visibleCount);
|
||||||
|
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
|
||||||
|
|
||||||
|
const formatTimestamp = useCallback((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(parsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && groups.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
|
||||||
|
Gruppen werden geladen ...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedGroups.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
|
||||||
|
Keine Gruppen gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{group.description ? (
|
||||||
|
group.description
|
||||||
|
) : (
|
||||||
|
<span className="text-blue-gray-400">–</span>
|
||||||
|
)}
|
||||||
|
</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-blue-gray-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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">{formatTimestamp(group.createdAt)}</td>
|
||||||
|
<td className="px-6 py-4">{formatTimestamp(group.updatedAt)}</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">
|
||||||
|
{!loading && groups.length === 0
|
||||||
|
? "Noch keine Gruppen angelegt."
|
||||||
|
: `${filteredCount.toLocaleString("de-DE")} Gruppen gefunden`}
|
||||||
|
</Typography>
|
||||||
|
<PaginationControls disabled={loading && groups.length === 0} />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Usergroups;
|
export default Usergroups;
|
||||||
|
|||||||
@@ -5,23 +5,317 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardBody,
|
CardBody,
|
||||||
Typography,
|
Typography,
|
||||||
Avatar,
|
|
||||||
Chip,
|
Chip,
|
||||||
Tooltip,
|
|
||||||
Progress,
|
|
||||||
Collapse,
|
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
Input
|
||||||
Select,
|
|
||||||
Option,
|
|
||||||
Input,
|
|
||||||
useSelect
|
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
|
||||||
export function Users() {
|
export function Users() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { maintenance } = useMaintenance();
|
||||||
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
perPageOptions,
|
||||||
|
perPageIsAll,
|
||||||
|
handlePerPageChange,
|
||||||
|
setPage,
|
||||||
|
setTotals,
|
||||||
|
resetPagination
|
||||||
|
} = usePage();
|
||||||
|
|
||||||
|
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/users");
|
||||||
|
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const sorted = items
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
username: item.username || "",
|
||||||
|
email: item.email || "",
|
||||||
|
isActive: Boolean(item.isActive),
|
||||||
|
lastLogin: item.lastLogin || null,
|
||||||
|
createdAt: item.createdAt || null,
|
||||||
|
updatedAt: item.updatedAt || null,
|
||||||
|
groups: Array.isArray(item.groups) ? item.groups : []
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username, "de-DE"));
|
||||||
|
setUsers(sorted);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
|
||||||
|
setError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzer konnten nicht geladen werden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((event) => {
|
||||||
|
setSearchQuery(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [searchQuery, page, setPage]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
return users.filter((user) => {
|
||||||
|
const haystacks = [
|
||||||
|
user.username,
|
||||||
|
user.email,
|
||||||
|
...user.groups
|
||||||
|
].map((value) => String(value || "").toLowerCase());
|
||||||
|
return haystacks.some((value) => value.includes(query));
|
||||||
|
});
|
||||||
|
}, [users, searchQuery]);
|
||||||
|
|
||||||
|
const perPageNumber = useMemo(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}, [perPage, perPageIsAll]);
|
||||||
|
|
||||||
|
const paginatedUsers = useMemo(() => {
|
||||||
|
if (perPageIsAll || !perPageNumber) {
|
||||||
|
return filteredUsers;
|
||||||
|
}
|
||||||
|
const startIndex = (page - 1) * perPageNumber;
|
||||||
|
return filteredUsers.slice(startIndex, startIndex + perPageNumber);
|
||||||
|
}, [filteredUsers, page, perPageIsAll, perPageNumber]);
|
||||||
|
|
||||||
|
const filteredCount = filteredUsers.length;
|
||||||
|
const visibleCount = paginatedUsers.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
|
||||||
|
if (page > maxPages) {
|
||||||
|
setPage(maxPages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotals(filteredCount, visibleCount);
|
||||||
|
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
|
||||||
|
|
||||||
|
const formatTimestamp = useCallback((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(parsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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 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>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||||
|
<table className="w-full min-w-[720px] table-auto text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-blue-gray-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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} 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">
|
||||||
|
Keine Benutzer gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
|
<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" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-blue-gray-400">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Chip
|
||||||
|
value={user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||||
|
size="sm"
|
||||||
|
color={user.isActive ? "green" : "red"}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">{formatTimestamp(user.lastLogin)}</td>
|
||||||
|
<td className="px-6 py-4">{formatTimestamp(user.createdAt)}</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">
|
||||||
|
{!loading && users.length === 0
|
||||||
|
? "Noch keine Benutzer angelegt."
|
||||||
|
: `${filteredCount.toLocaleString("de-DE")} Benutzer gefunden`}
|
||||||
|
</Typography>
|
||||||
|
<PaginationControls disabled={loading && users.length === 0} />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user