This commit is contained in:
root
2025-10-28 14:09:42 +00:00
parent d65585b821
commit e96cd63f33
20 changed files with 1255 additions and 404 deletions
+47 -8
View File
@@ -3,6 +3,36 @@ import { db } from '../db/index.js';
export const SUPERUSER_GROUP_NAME = 'superuser';
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 DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]);
const pickRandomAvatarColor = () => {
if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) {
return DEFAULT_AVATAR_COLOR;
}
const index = Math.floor(Math.random() * AVATAR_COLORS.length);
return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR;
};
const normalizeAvatarColor = (value) => {
if (!value) return null;
const candidate = String(value).trim();
return AVATAR_COLOR_SET.has(candidate) ? candidate : null;
};
const selectGroupByName = db.prepare('SELECT * FROM user_groups WHERE name = ?');
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
@@ -26,13 +56,13 @@ const updateLastLogin = db.prepare(`
`);
const insertUser = db.prepare(`
INSERT INTO users (username, email, password_hash, password_salt, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateUser = db.prepare(`
UPDATE users
SET username = ?, email = ?, password_hash = ?, password_salt = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP
SET username = ?, email = ?, password_hash = ?, password_salt = ?, is_active = 1, avatar_color = COALESCE(?, avatar_color), updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
@@ -63,15 +93,19 @@ const countSuperusers = db.prepare(`
WHERE g.name = ?
`);
const creationTransaction = db.transaction(({ username, email, passwordHash, passwordSalt, groupId }) => {
const creationTransaction = db.transaction(({ username, email, passwordHash, passwordSalt, groupId, avatarColor }) => {
const existingUser = selectUserByUsername.get(username) || selectUserByEmail.get(email);
let userId;
if (existingUser) {
updateUser.run(username, email, passwordHash, passwordSalt, existingUser.id);
const existingColor = normalizeAvatarColor(existingUser.avatar_color);
const normalizedNewColor = normalizeAvatarColor(avatarColor);
const colorToPersist = existingColor || normalizedNewColor || DEFAULT_AVATAR_COLOR;
updateUser.run(username, email, passwordHash, passwordSalt, colorToPersist, existingUser.id);
userId = existingUser.id;
} else {
const result = insertUser.run(username, email, passwordHash, passwordSalt);
const colorToPersist = normalizeAvatarColor(avatarColor) || DEFAULT_AVATAR_COLOR;
const result = insertUser.run(username, email, passwordHash, passwordSalt, colorToPersist);
userId = result.lastInsertRowid;
}
@@ -164,19 +198,24 @@ function createOrUpdateSuperuser({ username, email, password }) {
const { hash, salt } = hashPassword(password);
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
const avatarColor = pickRandomAvatarColor();
const user = creationTransaction({
username: normalizedUsername,
email: normalizedEmail,
passwordHash: hash,
passwordSalt: salt,
groupId: group.id
groupId: group.id,
avatarColor
});
const persistedColor = normalizeAvatarColor(user.avatar_color) || avatarColor || DEFAULT_AVATAR_COLOR;
return {
id: user.id,
username: user.username,
email: user.email
email: user.email,
avatarColor: persistedColor
};
}
+12
View File
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT,
avatar_color TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -130,6 +131,17 @@ console.log('✅ settings table ready');
db.exec(createUsersTable);
console.log('✅ users table ready');
try {
const userColumns = db.prepare('PRAGMA table_info(users)').all();
const hasAvatarColor = userColumns.some((column) => column.name === 'avatar_color');
if (!hasAvatarColor) {
db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT');
console.log('️ avatar_color column hinzugefügt');
}
} catch (err) {
console.error('⚠️ Konnte avatar_color Spalte nicht prüfen/erstellen:', err.message);
}
db.exec(createUserGroupsTable);
console.log('✅ user_groups table ready');
+50 -2
View File
@@ -45,7 +45,7 @@ import {
removeServer,
setServerApiKey
} from './setup/index.js';
import { listUsers } from './users/index.js';
import { listUsers, getUserById, updateUserGroups } from './users/index.js';
import { listGroups } from './groups/index.js';
dotenv.config();
@@ -145,7 +145,8 @@ const PUBLIC_API_ROUTES = [
const sanitizeUser = (user) => ({
id: user.id,
username: user.username,
email: user.email
email: user.email,
avatarColor: user.avatar_color || null
});
const cleanupExpiredSessions = () => {
@@ -1683,6 +1684,53 @@ app.get('/api/users', (req, res) => {
}
});
app.get('/api/users/:userId', (req, res) => {
const { userId } = req.params;
const numericId = Number(userId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return res.status(400).json({ error: 'INVALID_USER_ID' });
}
try {
const user = getUserById(numericId);
if (!user) {
return res.status(404).json({ error: 'USER_NOT_FOUND' });
}
res.json({ item: user });
} catch (error) {
console.error(`⚠️ [Users] Abruf der Benutzerdetails fehlgeschlagen (${userId}):`, error);
res.status(500).json({ error: 'USER_FETCH_FAILED' });
}
});
app.put('/api/users/:userId/groups', (req, res) => {
const { userId } = req.params;
const numericId = Number(userId);
const { groupIds } = req.body ?? {};
if (!Number.isFinite(numericId) || numericId <= 0) {
return res.status(400).json({ error: 'INVALID_USER_ID' });
}
try {
const updatedUser = updateUserGroups(numericId, Array.isArray(groupIds) ? groupIds : []);
res.json({ item: updatedUser });
} catch (error) {
if (error.code === 'INVALID_USER_ID') {
return res.status(400).json({ error: 'INVALID_USER_ID' });
}
if (error.code === 'USER_NOT_FOUND') {
return res.status(404).json({ error: 'USER_NOT_FOUND' });
}
if (error.code === 'GROUP_NOT_FOUND') {
return res.status(400).json({ error: 'GROUP_NOT_FOUND', details: error.missingGroupIds || [] });
}
console.error(`⚠️ [Users] Aktualisierung der Gruppenzuordnung fehlgeschlagen (${userId}):`, error);
res.status(500).json({ error: 'USER_GROUPS_UPDATE_FAILED' });
}
});
app.get('/api/groups', (req, res) => {
try {
const groups = listGroups();
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
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+4 -2
View File
@@ -32,8 +32,10 @@
<!-- 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-af8f32f4.js"></script>
<link rel="stylesheet" href="/assets/index-58708f0f.css">
<script type="module" crossorigin src="/assets/index-ec92bae1.js"></script>
<link rel="stylesheet" href="/assets/index-ecd5fb94.css">
</head>
<body>
<div id="root"></div>
+105 -6
View File
@@ -6,10 +6,11 @@ const selectUsersWithGroups = db.prepare(`
u.username,
u.email,
u.is_active,
u.avatar_color,
u.last_login,
u.created_at,
u.updated_at,
GROUP_CONCAT(g.name, '|||') AS group_names
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
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
@@ -17,13 +18,54 @@ const selectUsersWithGroups = db.prepare(`
ORDER BY u.username COLLATE NOCASE
`);
const normalizeGroupNames = (rawNames) => {
if (!rawNames) {
const selectUserWithGroupsById = db.prepare(`
SELECT
u.id,
u.username,
u.email,
u.is_active,
u.avatar_color,
u.last_login,
u.created_at,
u.updated_at,
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
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
WHERE u.id = ?
GROUP BY u.id
`);
const deleteMembershipsByUser = db.prepare(`
DELETE FROM user_group_memberships
WHERE user_id = ?
`);
const insertMembershipForUser = db.prepare(`
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
VALUES (?, ?)
`);
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
const parseGroupPairs = (rawPairs) => {
if (!rawPairs) {
return [];
}
return rawNames
return rawPairs
.split('|||')
.map((name) => String(name || '').trim())
.map((entry) => {
const [idPart, namePart] = entry.split('::');
const groupName = String(namePart || '').trim();
if (!groupName) {
return null;
}
const numericId = Number(idPart);
return {
id: Number.isFinite(numericId) ? numericId : null,
name: groupName
};
})
.filter(Boolean);
};
@@ -32,13 +74,70 @@ const sanitizeUserRecord = (row) => ({
username: row.username,
email: row.email,
isActive: Boolean(row.is_active),
avatarColor: row.avatar_color || null,
lastLogin: row.last_login || null,
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
groups: normalizeGroupNames(row.group_names)
groups: parseGroupPairs(row.group_pairs)
});
export function listUsers() {
const rows = selectUsersWithGroups.all();
return rows.map(sanitizeUserRecord);
}
export function getUserById(userId) {
const row = selectUserWithGroupsById.get(userId);
return row ? sanitizeUserRecord(row) : null;
}
const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
deleteMembershipsByUser.run(userId);
groupIds.forEach((groupId) => {
insertMembershipForUser.run(userId, groupId);
});
});
export function updateUserGroups(userId, groupIds) {
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = getUserById(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const normalizedGroupIds = Array.isArray(groupIds)
? Array.from(
new Set(
groupIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
)
)
: [];
const missingGroupIds = [];
normalizedGroupIds.forEach((groupId) => {
const groupRow = selectGroupById.get(groupId);
if (!groupRow) {
missingGroupIds.push(groupId);
}
});
if (missingGroupIds.length > 0) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
error.missingGroupIds = missingGroupIds;
throw error;
}
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
return getUserById(numericUserId);
}
+2
View File
@@ -32,6 +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>
<link rel="stylesheet" href="/src/tailwind.css" />
</head>
<body>
<div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+2
View File
@@ -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 />
+2 -1
View File
@@ -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>
);
+1
View File
@@ -3,3 +3,4 @@ export * from "@/pages/dashboard/maintenance";
export * from "@/pages/dashboard/logs";
export * from "@/pages/dashboard/users";
export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/userDetails";
+46 -58
View File
@@ -620,7 +620,7 @@ 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">
<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>
@@ -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,76 +945,64 @@ 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">
<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>
<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="border-b border-stormGrey-50 py-3 px-5 text-left"
>
<Typography
variant="small"
className="text-[11px] font-bold uppercase text-stormGrey-400"
>
<th key={el} className="px-6 py-4 font-semibold">
{el}
</Typography>
</th>
))}
</tr>
</thead>
<tbody>
{logs.map((log) => {
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-stormGrey-400">
Logs werden geladen ...
</td>
</tr>
) : 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 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)
: '---';
const rowClass = index === logs.length - 1 ? "" : "border-b border-blue-gray-50";
return (
<tr key={log.id}>
<td className={className}>
<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={className}>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="font-medium">{stackDisplayName}</span>
<Typography
variant="small"
>
<Typography variant="small">
{showStackId && (
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
)}
</Typography>
</div>
</td>
<td className={className}>
<Typography
variant="small"
>
<td className="px-6 py-4">
<Typography variant="small">
{redeployTypeLabel}
</Typography>
</td>
<td className={className}>
<td className="px-6 py-4">
<Typography
variant="small"
className={`font-semibold ${statusClass}`}
@@ -1022,24 +1010,18 @@ export function Logs() {
{log.status}
</Typography>
</td>
<td className={className}>
<Typography
variant="small"
>
<td className="px-6 py-4">
<Typography variant="small">
{log.message || "-"}
</Typography>
</td>
<td className={className}>
<Typography
variant="small"
>
<td className="px-6 py-4">
<Typography variant="small">
{log.endpoint ?? "-"}
</Typography>
</td>
<td className={className}>
<Typography
variant="small"
>
<td className="px-6 py-4">
<Typography variant="small">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
@@ -1050,13 +1032,19 @@ export function Logs() {
</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;
+64 -62
View File
@@ -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);
@@ -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"
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
>
<div>
<Typography variant="h4" color="blue-gray">
Benutzergruppen
<span>Benutzergruppen</span>
</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">
<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 Gruppenname, Beschreibung oder Mitglied"
label="Suchen nach Name, E-Mail oder Gruppe"
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"
<div className="md:mt-0 mt-8 md:flex-1">
<Select
variant="static"
label="Einträge pro Seite"
onChange={noop}
value={perPage}
>
Suche zurücksetzen
</Button>
)}
{perPageOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</div>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</div>
)}
{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">
<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-blue-gray-400"></span>
<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>
);
})
+128 -43
View File
@@ -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,22 +197,25 @@ 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"
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
>
<div>
<Typography variant="h4" color="blue-gray">
Benutzerverwaltung
</Typography>
<Typography variant="small" color="gray" className="font-normal">
Übersicht aller im System angelegten Benutzer.
<span>Benutzerverwaltung</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="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
)}
<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
@@ -206,17 +241,8 @@ export function Users() {
>
{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">
</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>