User/Usergroup Pages first draft

This commit is contained in:
root
2025-10-27 19:36:44 +00:00
parent a76567969a
commit d65585b821
12 changed files with 769 additions and 51 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+50
View File
@@ -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);
}
+22
View File
@@ -45,6 +45,8 @@ import {
removeServer,
setServerApiKey
} from './setup/index.js';
import { listUsers } from './users/index.js';
import { listGroups } from './groups/index.js';
dotenv.config();
@@ -1671,6 +1673,26 @@ app.get('/api/auth/session', (req, res) => {
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
app.get('/api/auth/superuser/status', (req, res) => {
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
+2 -2
View File
@@ -32,8 +32,8 @@
<!-- Nepcha Analytics (nepcha.com) -->
<!-- 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 type="module" crossorigin src="/assets/index-999a8a11.js"></script>
<link rel="stylesheet" href="/assets/index-4350b70a.css">
<script type="module" crossorigin src="/assets/index-af8f32f4.js"></script>
<link rel="stylesheet" href="/assets/index-58708f0f.css">
</head>
<body>
<div id="root"></div>
+44
View File
@@ -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);
}
+317 -9
View File
@@ -5,25 +5,333 @@ import {
CardHeader,
CardBody,
Typography,
Avatar,
Chip,
Tooltip,
Progress,
Collapse,
Button,
ButtonGroup,
Select,
Option,
Input,
useSelect
Input
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
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;
+303 -9
View File
@@ -5,23 +5,317 @@ import {
CardHeader,
CardBody,
Typography,
Avatar,
Chip,
Tooltip,
Progress,
Collapse,
Button,
ButtonGroup,
Select,
Option,
Input,
useSelect
Input
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
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>
);
}