Update
This commit is contained in:
@@ -3,6 +3,36 @@ import { db } from '../db/index.js';
|
|||||||
|
|
||||||
export const SUPERUSER_GROUP_NAME = 'superuser';
|
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 selectGroupByName = db.prepare('SELECT * FROM user_groups WHERE name = ?');
|
||||||
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
|
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
|
||||||
|
|
||||||
@@ -26,13 +56,13 @@ const updateLastLogin = db.prepare(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const insertUser = db.prepare(`
|
const insertUser = db.prepare(`
|
||||||
INSERT INTO users (username, email, password_hash, password_salt, is_active, created_at, updated_at)
|
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateUser = db.prepare(`
|
const updateUser = db.prepare(`
|
||||||
UPDATE users
|
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 = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -63,15 +93,19 @@ const countSuperusers = db.prepare(`
|
|||||||
WHERE g.name = ?
|
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);
|
const existingUser = selectUserByUsername.get(username) || selectUserByEmail.get(email);
|
||||||
|
|
||||||
let userId;
|
let userId;
|
||||||
if (existingUser) {
|
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;
|
userId = existingUser.id;
|
||||||
} else {
|
} 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;
|
userId = result.lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,19 +198,24 @@ function createOrUpdateSuperuser({ username, email, password }) {
|
|||||||
|
|
||||||
const { hash, salt } = hashPassword(password);
|
const { hash, salt } = hashPassword(password);
|
||||||
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
|
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
|
||||||
|
const avatarColor = pickRandomAvatarColor();
|
||||||
|
|
||||||
const user = creationTransaction({
|
const user = creationTransaction({
|
||||||
username: normalizedUsername,
|
username: normalizedUsername,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
passwordHash: hash,
|
passwordHash: hash,
|
||||||
passwordSalt: salt,
|
passwordSalt: salt,
|
||||||
groupId: group.id
|
groupId: group.id,
|
||||||
|
avatarColor
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persistedColor = normalizeAvatarColor(user.avatar_color) || avatarColor || DEFAULT_AVATAR_COLOR;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email
|
email: user.email,
|
||||||
|
avatarColor: persistedColor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
password_salt TEXT,
|
password_salt TEXT,
|
||||||
|
avatar_color TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
last_login DATETIME,
|
last_login DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -130,6 +131,17 @@ console.log('✅ settings table ready');
|
|||||||
db.exec(createUsersTable);
|
db.exec(createUsersTable);
|
||||||
console.log('✅ users table ready');
|
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);
|
db.exec(createUserGroupsTable);
|
||||||
console.log('✅ user_groups table ready');
|
console.log('✅ user_groups table ready');
|
||||||
|
|
||||||
|
|||||||
+50
-2
@@ -45,7 +45,7 @@ import {
|
|||||||
removeServer,
|
removeServer,
|
||||||
setServerApiKey
|
setServerApiKey
|
||||||
} from './setup/index.js';
|
} from './setup/index.js';
|
||||||
import { listUsers } from './users/index.js';
|
import { listUsers, getUserById, updateUserGroups } from './users/index.js';
|
||||||
import { listGroups } from './groups/index.js';
|
import { listGroups } from './groups/index.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -145,7 +145,8 @@ const PUBLIC_API_ROUTES = [
|
|||||||
const sanitizeUser = (user) => ({
|
const sanitizeUser = (user) => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email
|
email: user.email,
|
||||||
|
avatarColor: user.avatar_color || null
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanupExpiredSessions = () => {
|
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) => {
|
app.get('/api/groups', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const groups = listGroups();
|
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 |
@@ -32,8 +32,10 @@
|
|||||||
<!-- 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-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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+105
-6
@@ -6,10 +6,11 @@ const selectUsersWithGroups = db.prepare(`
|
|||||||
u.username,
|
u.username,
|
||||||
u.email,
|
u.email,
|
||||||
u.is_active,
|
u.is_active,
|
||||||
|
u.avatar_color,
|
||||||
u.last_login,
|
u.last_login,
|
||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
GROUP_CONCAT(g.name, '|||') AS group_names
|
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||||
LEFT JOIN user_groups g ON g.id = m.group_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
|
ORDER BY u.username COLLATE NOCASE
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const normalizeGroupNames = (rawNames) => {
|
const selectUserWithGroupsById = db.prepare(`
|
||||||
if (!rawNames) {
|
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 [];
|
||||||
}
|
}
|
||||||
return rawNames
|
return rawPairs
|
||||||
.split('|||')
|
.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);
|
.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,13 +74,70 @@ const sanitizeUserRecord = (row) => ({
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
isActive: Boolean(row.is_active),
|
isActive: Boolean(row.is_active),
|
||||||
|
avatarColor: row.avatar_color || null,
|
||||||
lastLogin: row.last_login || null,
|
lastLogin: row.last_login || null,
|
||||||
createdAt: row.created_at || null,
|
createdAt: row.created_at || null,
|
||||||
updatedAt: row.updated_at || null,
|
updatedAt: row.updated_at || null,
|
||||||
groups: normalizeGroupNames(row.group_names)
|
groups: parseGroupPairs(row.group_pairs)
|
||||||
});
|
});
|
||||||
|
|
||||||
export function listUsers() {
|
export function listUsers() {
|
||||||
const rows = selectUsersWithGroups.all();
|
const rows = selectUsersWithGroups.all();
|
||||||
return rows.map(sanitizeUserRecord);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +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>
|
||||||
|
<link rel="stylesheet" href="/src/tailwind.css" />
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -9,6 +9,7 @@ import {
|
|||||||
Footer,
|
Footer,
|
||||||
} from "@/widgets/layout";
|
} from "@/widgets/layout";
|
||||||
import routes from "@/routes";
|
import routes from "@/routes";
|
||||||
|
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
|
||||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@@ -154,6 +155,7 @@ export function Dashboard() {
|
|||||||
<Route exact path={path} element={element} />
|
<Route exact path={path} element={element} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
<Route path="users/:userId" element={<UserDetails />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<div className="text-blue-gray-600">
|
<div className="text-blue-gray-600">
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import pattern from "@/assets/images/pattern.png";
|
||||||
|
|
||||||
export function SignIn() {
|
export function SignIn() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -195,7 +196,7 @@ export function SignIn() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-2/5 h-full hidden lg:block">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from "@/pages/dashboard/stacks";
|
|||||||
export * from "@/pages/dashboard/maintenance";
|
export * from "@/pages/dashboard/maintenance";
|
||||||
export * from "@/pages/dashboard/logs";
|
export * from "@/pages/dashboard/logs";
|
||||||
export * from "@/pages/dashboard/users";
|
export * from "@/pages/dashboard/users";
|
||||||
export * from "@/pages/dashboard/usergroups";
|
export * from "@/pages/dashboard/usergroups";
|
||||||
|
export * from "@/pages/dashboard/userDetails";
|
||||||
|
|||||||
@@ -148,20 +148,20 @@ export function Logs() {
|
|||||||
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
||||||
const [refreshSignal, setRefreshSignal] = useState(0);
|
const [refreshSignal, setRefreshSignal] = useState(0);
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const {
|
const {
|
||||||
maintenance: maintenanceMeta,
|
maintenance: maintenanceMeta,
|
||||||
update: updateState,
|
update: updateState,
|
||||||
script: scriptConfig,
|
script: scriptConfig,
|
||||||
ssh: sshConfig,
|
ssh: sshConfig,
|
||||||
} = useMaintenance();
|
} = useMaintenance();
|
||||||
|
|
||||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
const maintenanceMessage = maintenanceMeta?.message;
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
const updateRunning = Boolean(updateState?.running);
|
const updateRunning = Boolean(updateState?.running);
|
||||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
@@ -620,8 +620,8 @@ export function Logs() {
|
|||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const toggleOpen = () => setOpen((cur) => !cur);
|
const toggleOpen = () => setOpen((cur) => !cur);
|
||||||
return (
|
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">
|
{(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">
|
<div className="flex flex-col gap-1">
|
||||||
<span>
|
<span>
|
||||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
@@ -635,14 +635,14 @@ export function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Card>
|
<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">
|
<Typography variant="h6" color="white">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleFilters}
|
onClick={handleToggleFilters}
|
||||||
className="flex w-full items-center justify-between"
|
className="flex w-full items-center justify-between"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span>Filter und Optionen</span>
|
<span>Logs</span>
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
|
|
||||||
<span className="rounded-full bg-blue-gray-500/80 px-2 py-0.5 text-xs text-white">
|
<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>
|
||||||
<span className="text-xs uppercase tracking-wide text-gray-400">
|
<span className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
{filtersOpen ? "Ausblenden" : "Anzeigen"}
|
{filtersOpen ? "Filter ausblenden" : "Filter anzeigen"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody className="pt-0">
|
||||||
{filtersOpen && (
|
{filtersOpen && (
|
||||||
<div>
|
<div className="mb-8">
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -945,118 +945,106 @@ export function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||||
</CardBody>
|
<table className="w-full min-w-[720px] table-auto text-left">
|
||||||
</Card>
|
<thead>
|
||||||
<Card>
|
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
|
||||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
|
||||||
<Typography
|
<th key={el} className="px-6 py-4 font-semibold">
|
||||||
variant="h6"
|
|
||||||
color="white"
|
|
||||||
className="flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<span>Logs</span>
|
|
||||||
</Typography>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
|
|
||||||
<table className="w-full min-w-[640px] table-auto">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
|
|
||||||
<th
|
|
||||||
key={el}
|
|
||||||
className="border-b border-stormGrey-50 py-3 px-5 text-left"
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
className="text-[11px] font-bold uppercase text-stormGrey-400"
|
|
||||||
>
|
|
||||||
{el}
|
{el}
|
||||||
</Typography>
|
</th>
|
||||||
</th>
|
))}
|
||||||
))}
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{loading ? (
|
||||||
{logs.map((log) => {
|
<tr>
|
||||||
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
|
<td colSpan={7} className="px-6 py-8 text-center text-stormGrey-400">
|
||||||
const className = "py-3 px-5";
|
Logs werden geladen ...
|
||||||
const stackDisplayName = log.stackName || "Unbekannt";
|
|
||||||
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
|
|
||||||
const redeployTypeLabel = log.redeployType
|
|
||||||
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
|
|
||||||
: '---';
|
|
||||||
return (
|
|
||||||
<tr key={log.id}>
|
|
||||||
<td className={className}>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
className="mb-1 block text-xs font-medium text-stormGrey-600">
|
|
||||||
{formatTimestamp(log.timestamp)}
|
|
||||||
</Typography>
|
|
||||||
</td>
|
|
||||||
<td className={className}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{stackDisplayName}</span>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
>
|
|
||||||
{showStackId && (
|
|
||||||
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className={className}>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
>
|
|
||||||
{redeployTypeLabel}
|
|
||||||
</Typography>
|
|
||||||
</td>
|
|
||||||
<td className={className}>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
className={`font-semibold ${statusClass}`}
|
|
||||||
>
|
|
||||||
{log.status}
|
|
||||||
</Typography>
|
|
||||||
</td>
|
|
||||||
<td className={className}>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
>
|
|
||||||
{log.message || "-"}
|
|
||||||
</Typography>
|
|
||||||
</td>
|
|
||||||
<td className={className}>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
>
|
|
||||||
{log.endpoint ?? "-"}
|
|
||||||
</Typography>
|
|
||||||
</td>
|
|
||||||
<td className={className}>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteLog(log.id)}
|
|
||||||
disabled={actionLoading}
|
|
||||||
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60">
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
</Typography>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
) : logs.length === 0 ? (
|
||||||
}
|
<tr>
|
||||||
)}
|
<td colSpan={7} className="px-6 py-8 text-center text-stormGrey-400">
|
||||||
</tbody>
|
Keine Logs gefunden.
|
||||||
</table>
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log, index) => {
|
||||||
|
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
|
||||||
|
const stackDisplayName = log.stackName || "Unbekannt";
|
||||||
|
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
|
||||||
|
const redeployTypeLabel = log.redeployType
|
||||||
|
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
|
||||||
|
: '---';
|
||||||
|
const rowClass = index === logs.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||||
|
return (
|
||||||
|
<tr key={log.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className="mb-1 block text-xs font-medium text-stormGrey-600">
|
||||||
|
{formatTimestamp(log.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{stackDisplayName}</span>
|
||||||
|
<Typography variant="small">
|
||||||
|
{showStackId && (
|
||||||
|
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small">
|
||||||
|
{redeployTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className={`font-semibold ${statusClass}`}
|
||||||
|
>
|
||||||
|
{log.status}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small">
|
||||||
|
{log.message || "-"}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small">
|
||||||
|
{log.endpoint ?? "-"}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLog(log.id)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Typography variant="small" color="gray">
|
||||||
|
{""}
|
||||||
|
</Typography>
|
||||||
|
<PaginationControls disabled={loading && logs.length === 0} />
|
||||||
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
<PaginationControls disabled={actionLoading} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
CardFooter,
|
||||||
|
Tabs,
|
||||||
|
TabsHeader,
|
||||||
|
Tab,
|
||||||
|
Switch,
|
||||||
|
Tooltip,
|
||||||
|
Avatar,
|
||||||
|
Checkbox
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
ChatBubbleLeftEllipsisIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
PencilIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { ProfileInfoCard } from "@/widgets/cards";
|
||||||
|
import { platformSettingsData, projectsData } from "@/data";
|
||||||
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
|
||||||
|
const DEFAULT_AVATAR = { background: "bg-blue-gray-500", text: "text-white" };
|
||||||
|
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'bg-arcticBlue-600',
|
||||||
|
'bg-copperRust-500',
|
||||||
|
'bg-sunsetCoral-600',
|
||||||
|
'bg-mintTea-400',
|
||||||
|
'bg-lavenderSmoke-500',
|
||||||
|
'bg-emeraldMist-500',
|
||||||
|
'bg-roseQuartz-500',
|
||||||
|
'bg-auroraTeal-500',
|
||||||
|
'bg-citrusPunch-500',
|
||||||
|
'bg-mossGreen-400'
|
||||||
|
];
|
||||||
|
const _ = AVATAR_COLORS.join(" ");
|
||||||
|
|
||||||
|
|
||||||
|
const normalizeUserGroups = (rawGroups) => {
|
||||||
|
if (!Array.isArray(rawGroups)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return rawGroups
|
||||||
|
.map((group) => {
|
||||||
|
if (group && typeof group === "object") {
|
||||||
|
const id = Number(group.id);
|
||||||
|
const name = typeof group.name === "string" ? group.name : "";
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(id) ? id : null,
|
||||||
|
name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof group === "string") {
|
||||||
|
const name = group.trim();
|
||||||
|
return name ? { id: null, name } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapUser = (item) => ({
|
||||||
|
id: item?.id ?? null,
|
||||||
|
username: item?.username || "",
|
||||||
|
email: item?.email || "",
|
||||||
|
isActive: Boolean(item?.isActive),
|
||||||
|
avatarColor: item?.avatarColor || null,
|
||||||
|
lastLogin: item?.lastLogin || null,
|
||||||
|
createdAt: item?.createdAt || null,
|
||||||
|
updatedAt: item?.updatedAt || null,
|
||||||
|
groups: normalizeUserGroups(item?.groups)
|
||||||
|
});
|
||||||
|
|
||||||
|
export function UserDetails() {
|
||||||
|
const { userId } = useParams();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { maintenance } = useMaintenance();
|
||||||
|
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
const [availableGroups, setAvailableGroups] = useState([]);
|
||||||
|
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||||
|
const [groupsError, setGroupsError] = useState("");
|
||||||
|
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
||||||
|
const [savingGroups, setSavingGroups] = useState(false);
|
||||||
|
|
||||||
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
|
||||||
|
const numericUserId = useMemo(() => {
|
||||||
|
const asNumber = Number(userId);
|
||||||
|
return Number.isFinite(asNumber) ? asNumber : null;
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const formatTimestamp = useCallback((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(parsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserDetails = useCallback(async () => {
|
||||||
|
if (!numericUserId) {
|
||||||
|
setError("Ungültige Benutzer-ID.");
|
||||||
|
setUser(null);
|
||||||
|
setHasLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${numericUserId}`);
|
||||||
|
const item = mapUser(response.data?.item);
|
||||||
|
if (!item.id) {
|
||||||
|
throw new Error("USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
setUser(item);
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Benutzerdetails konnten nicht geladen werden.";
|
||||||
|
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der angeforderte Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die angegebene Benutzer-ID ist ungültig.";
|
||||||
|
} else if (err.response?.status === 404) {
|
||||||
|
message = "Der angeforderte Benutzer existiert nicht.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(null);
|
||||||
|
setError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Fehler beim Laden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
|
}
|
||||||
|
}, [numericUserId, showToast]);
|
||||||
|
|
||||||
|
const fetchAvailableGroups = useCallback(async () => {
|
||||||
|
setGroupsLoading(true);
|
||||||
|
setGroupsError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/groups");
|
||||||
|
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const normalized = items
|
||||||
|
.map((item) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
name: item.name || "",
|
||||||
|
description: item.description || "",
|
||||||
|
memberCount: Number.isFinite(Number(item.memberCount)) ? Number(item.memberCount) : 0
|
||||||
|
}))
|
||||||
|
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||||
|
setAvailableGroups(normalized);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden.";
|
||||||
|
setGroupsError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzergruppen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setGroupsLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserDetails();
|
||||||
|
}, [fetchUserDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAvailableGroups();
|
||||||
|
}, [fetchAvailableGroups]);
|
||||||
|
|
||||||
|
const originalGroupIds = useMemo(() => {
|
||||||
|
if (!user || !Array.isArray(user.groups)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return user.groups
|
||||||
|
.map((group) => Number(group.id))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedGroupIds([...originalGroupIds]);
|
||||||
|
}, [originalGroupIds]);
|
||||||
|
|
||||||
|
const hasGroupChanges = useMemo(() => {
|
||||||
|
const normalize = (ids) =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
ids
|
||||||
|
.map((id) => Number(id))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0)
|
||||||
|
)
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const next = normalize(selectedGroupIds);
|
||||||
|
const baseline = normalize(originalGroupIds);
|
||||||
|
|
||||||
|
if (next.length !== baseline.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.some((value, index) => value !== baseline[index]);
|
||||||
|
}, [selectedGroupIds, originalGroupIds]);
|
||||||
|
|
||||||
|
const handleToggleGroup = useCallback((groupId) => {
|
||||||
|
const numeric = Number(groupId);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedGroupIds((prev) => {
|
||||||
|
if (prev.includes(numeric)) {
|
||||||
|
return prev.filter((id) => id !== numeric);
|
||||||
|
}
|
||||||
|
return [...prev, numeric];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResetGroups = useCallback(() => {
|
||||||
|
setSelectedGroupIds([...originalGroupIds]);
|
||||||
|
}, [originalGroupIds]);
|
||||||
|
|
||||||
|
const handleSaveGroups = useCallback(async () => {
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadGroups = Array.from(
|
||||||
|
new Set(
|
||||||
|
selectedGroupIds
|
||||||
|
.map((id) => Number(id))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setSavingGroups(true);
|
||||||
|
setGroupsError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups });
|
||||||
|
const updatedUser = mapUser(response.data?.item || response.data?.user);
|
||||||
|
setUser(updatedUser);
|
||||||
|
setSelectedGroupIds(
|
||||||
|
Array.isArray(updatedUser.groups)
|
||||||
|
? updatedUser.groups
|
||||||
|
.map((group) => Number(group.id))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Benutzergruppen aktualisiert",
|
||||||
|
description: "Die Gruppenzuordnung wurde gespeichert."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = err.message || "Benutzergruppen konnten nicht aktualisiert werden.";
|
||||||
|
if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer konnte nicht gefunden werden.";
|
||||||
|
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Mindestens eine ausgewählte Gruppe existiert nicht mehr.";
|
||||||
|
} else if (typeof serverError === "string" && serverError.length > 0) {
|
||||||
|
message = serverError;
|
||||||
|
}
|
||||||
|
setGroupsError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzergruppen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingGroups(false);
|
||||||
|
}
|
||||||
|
}, [user, selectedGroupIds, showToast]);
|
||||||
|
|
||||||
|
const avatarLabel = useMemo(() => {
|
||||||
|
const source = (user?.username || user?.email || "").trim();
|
||||||
|
if (!source) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
return source.charAt(0).toUpperCase();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover bg-center">
|
||||||
|
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||||
|
</div>
|
||||||
|
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||||
|
<CardBody className="p-4">
|
||||||
|
<div className="mb-10 flex items-center justify-between flex-wrap gap-6">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div
|
||||||
|
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${user?.avatarColor}`}
|
||||||
|
aria-label={user?.username || "Benutzeravatar"}
|
||||||
|
>
|
||||||
|
{avatarLabel}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography variant="h5" color="blue-gray" className="mb-1">
|
||||||
|
{user?.username}
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-xs font-semibold uppercase tracking-wide text-stormGrey-400">
|
||||||
|
User-ID: {user?.id || "–"}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{loading && !user && (
|
||||||
|
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Benutzerdaten werden geladen ...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="gird-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||||
|
Platform Settings
|
||||||
|
</Typography>
|
||||||
|
<div className="flex flex-col gap-12">
|
||||||
|
{platformSettingsData.map(({ title, options }) => (
|
||||||
|
<div key={title}>
|
||||||
|
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{options.map(({ checked, label }) => (
|
||||||
|
<Switch
|
||||||
|
key={label}
|
||||||
|
id={label}
|
||||||
|
label={label}
|
||||||
|
defaultChecked={checked}
|
||||||
|
labelProps={{
|
||||||
|
className: "text-sm font-normal text-blue-gray-500",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProfileInfoCard
|
||||||
|
title="Profile Information"
|
||||||
|
description="Hi, I'm Alec Thompson, Decisions: If you can't decide, the answer is no. If two equally difficult paths, choose the one more painful in the short term (pain avoidance is creating an illusion of equality)."
|
||||||
|
details={{
|
||||||
|
"first name": "Alec M. Thompson",
|
||||||
|
mobile: "(44) 123 1234 123",
|
||||||
|
email: "alecthompson@mail.com",
|
||||||
|
location: "USA",
|
||||||
|
social: (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<i className="fa-brands fa-facebook text-blue-700" />
|
||||||
|
<i className="fa-brands fa-twitter text-blue-400" />
|
||||||
|
<i className="fa-brands fa-instagram text-purple-500" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
action={
|
||||||
|
<Tooltip content="Edit Profile">
|
||||||
|
<PencilIcon className="h-4 w-4 cursor-pointer text-blue-gray-500" />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div id="platform" className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
Benutzergruppen
|
||||||
|
</Typography>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
color="blue-gray"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleResetGroups}
|
||||||
|
disabled={!hasGroupChanges || savingGroups || !user}
|
||||||
|
>
|
||||||
|
Änderungen verwerfen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="green"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleSaveGroups}
|
||||||
|
disabled={maintenanceActive || savingGroups || !hasGroupChanges || !user}
|
||||||
|
>
|
||||||
|
{savingGroups ? "Speichert ..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{maintenanceActive && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Wartungsmodus aktiv – Änderungen sind deaktiviert.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupsError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{groupsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupsLoading && availableGroups.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Benutzergruppen werden geladen ...</span>
|
||||||
|
</div>
|
||||||
|
) : availableGroups.length === 0 ? (
|
||||||
|
<Typography variant="small" className="text-sm text-stormGrey-500">
|
||||||
|
Es sind noch keine Benutzergruppen vorhanden.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{availableGroups.map((group) => {
|
||||||
|
const checked = selectedGroupIds.includes(group.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={group.id}
|
||||||
|
className={`flex items-start justify-between gap-3 rounded-lg border px-4 py-3 transition ${
|
||||||
|
checked ? "border-auroraTeal-500 bg-auroraTeal-500/10" : "border-blue-gray-100 bg-white"
|
||||||
|
} ${maintenanceActive ? "opacity-70" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
ripple={false}
|
||||||
|
className="mt-1"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handleToggleGroup(group.id)}
|
||||||
|
disabled={maintenanceActive || savingGroups || !user}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography variant="small" className="font-semibold text-blue-gray-900">
|
||||||
|
{group.name}
|
||||||
|
</Typography>
|
||||||
|
{group.description && (
|
||||||
|
<Typography variant="small" className="text-xs text-stormGrey-500">
|
||||||
|
{group.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Typography variant="small" className="text-xs font-medium text-stormGrey-500">
|
||||||
|
Mitglieder: {group.memberCount}
|
||||||
|
</Typography>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||||
|
Projects
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className="font-normal text-blue-gray-500"
|
||||||
|
>
|
||||||
|
Architects design houses
|
||||||
|
</Typography>
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-12 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{projectsData.map(
|
||||||
|
({ img, title, description, tag, route, members }) => (
|
||||||
|
<Card key={title} color="transparent" shadow={false}>
|
||||||
|
<CardHeader
|
||||||
|
floated={false}
|
||||||
|
color="gray"
|
||||||
|
className="mx-0 mt-0 mb-4 h-64 xl:h-40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={title}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="py-0 px-1">
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className="font-normal text-blue-gray-500"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
color="blue-gray"
|
||||||
|
className="mt-1 mb-2"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className="font-normal text-blue-gray-500"
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1">
|
||||||
|
<Link to={route}>
|
||||||
|
<Button variant="outlined" size="sm">
|
||||||
|
view project
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
{members.map(({ img, name }, key) => (
|
||||||
|
<Tooltip key={name} content={name}>
|
||||||
|
<Avatar
|
||||||
|
src={img}
|
||||||
|
alt={name}
|
||||||
|
size="xs"
|
||||||
|
variant="circular"
|
||||||
|
className={`cursor-pointer border-2 border-white ${key === 0 ? "" : "-ml-2.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserDetails;
|
||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Chip,
|
Chip,
|
||||||
Button,
|
Button,
|
||||||
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";
|
||||||
@@ -35,6 +38,7 @@ export function Usergroups() {
|
|||||||
} = usePage();
|
} = usePage();
|
||||||
|
|
||||||
useEffect(() => () => resetPagination(), [resetPagination]);
|
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||||
|
const noop = useCallback(() => { }, []);
|
||||||
|
|
||||||
const fetchGroups = useCallback(async () => {
|
const fetchGroups = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -50,9 +54,9 @@ export function Usergroups() {
|
|||||||
memberCount: Number(item.memberCount) || 0,
|
memberCount: Number(item.memberCount) || 0,
|
||||||
members: Array.isArray(item.members)
|
members: Array.isArray(item.members)
|
||||||
? item.members.map((member) => ({
|
? item.members.map((member) => ({
|
||||||
id: member.id,
|
id: member.id,
|
||||||
username: member.username || ""
|
username: member.username || ""
|
||||||
})).filter((member) => member.username)
|
})).filter((member) => member.username)
|
||||||
: [],
|
: [],
|
||||||
createdAt: item.createdAt || null,
|
createdAt: item.createdAt || null,
|
||||||
updatedAt: item.updatedAt || null
|
updatedAt: item.updatedAt || null
|
||||||
@@ -170,77 +174,54 @@ export function Usergroups() {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<Card className="border border-blue-gray-100 shadow-sm">
|
<Card className="border border-blue-gray-100 shadow-sm">
|
||||||
<CardHeader
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
floated={false}
|
<Typography
|
||||||
shadow={false}
|
variant="h6"
|
||||||
color="transparent"
|
color="white"
|
||||||
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
|
>
|
||||||
>
|
<span>Benutzergruppen</span>
|
||||||
<div>
|
</Typography>
|
||||||
<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>
|
</CardHeader>
|
||||||
<CardBody className="pt-0">
|
<CardBody className="pt-0">
|
||||||
|
|
||||||
{maintenanceActive && (
|
{maintenanceActive && (
|
||||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<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.
|
Wartungsmodus aktiv – Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
<div className="mb-8">
|
||||||
<div className="w-full md:max-w-md">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<Input
|
<div className="md:flex-1">
|
||||||
label="Suchen nach Gruppenname, Beschreibung oder Mitglied"
|
<Input
|
||||||
value={searchQuery}
|
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||||
onChange={handleSearchChange}
|
value={searchQuery}
|
||||||
disabled={loading && !groups.length}
|
onChange={handleSearchChange}
|
||||||
crossOrigin=""
|
disabled={loading && !groups.length}
|
||||||
/>
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:mt-0 mt-8 md:flex-1">
|
||||||
|
<Select
|
||||||
|
variant="static"
|
||||||
|
label="Einträge pro Seite"
|
||||||
|
onChange={noop}
|
||||||
|
value={perPage}
|
||||||
|
>
|
||||||
|
{perPageOptions.map(({ value, label }) => (
|
||||||
|
<Option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{searchQuery && (
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
color="blue-gray"
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
className="w-full md:w-auto"
|
|
||||||
>
|
|
||||||
Suche zurücksetzen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</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 && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
{error}
|
{error}
|
||||||
@@ -250,25 +231,27 @@ export function Usergroups() {
|
|||||||
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||||
<table className="w-full min-w-[720px] table-auto text-left">
|
<table className="w-full min-w-[720px] table-auto text-left">
|
||||||
<thead>
|
<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">Gruppenname</th>
|
||||||
<th className="px-6 py-4 font-semibold">Beschreibung</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">Mitglieder</th>
|
||||||
<th className="px-6 py-4 font-semibold">Anzahl</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">Erstellt am</th>
|
||||||
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
|
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Aktionen</th>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading && groups.length === 0 ? (
|
{loading && groups.length === 0 ? (
|
||||||
<tr>
|
<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 ...
|
Gruppen werden geladen ...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : paginatedGroups.length === 0 ? (
|
) : paginatedGroups.length === 0 ? (
|
||||||
<tr>
|
<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.
|
Keine Gruppen gefunden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -276,14 +259,20 @@ export function Usergroups() {
|
|||||||
paginatedGroups.map((group, index) => {
|
paginatedGroups.map((group, index) => {
|
||||||
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
|
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||||
return (
|
return (
|
||||||
<tr key={group.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
|
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||||
<td className="px-6 py-4 font-medium text-blue-gray-900">{group.name || "–"}</td>
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{group.description ? (
|
<Typography variant="small" className="font-medium text-stormGrey-900">
|
||||||
group.description
|
{group.name || "–"}
|
||||||
) : (
|
</Typography>
|
||||||
<span className="text-blue-gray-400">–</span>
|
</td>
|
||||||
)}
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small">
|
||||||
|
{group.description ? (
|
||||||
|
group.description
|
||||||
|
) : (
|
||||||
|
<span className="text-stormGrey-400">–</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{group.members.length > 0 ? (
|
{group.members.length > 0 ? (
|
||||||
@@ -299,7 +288,7 @@ export function Usergroups() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-blue-gray-400">Keine Mitglieder</span>
|
<span className="text-stormGrey-400">Keine Mitglieder</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -310,8 +299,21 @@ export function Usergroups() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">{formatTimestamp(group.createdAt)}</td>
|
<td className="px-6 py-4">
|
||||||
<td className="px-6 py-4">{formatTimestamp(group.updatedAt)}</td>
|
<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>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,12 +7,41 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Chip,
|
Chip,
|
||||||
Button,
|
Button,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
Input
|
Input
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
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";
|
||||||
|
|
||||||
|
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() {
|
export function Users() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -22,6 +51,8 @@ export function Users() {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { maintenance } = useMaintenance();
|
const { maintenance } = useMaintenance();
|
||||||
const maintenanceActive = Boolean(maintenance?.active);
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const noop = useCallback(() => { }, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
@@ -48,10 +79,11 @@ export function Users() {
|
|||||||
username: item.username || "",
|
username: item.username || "",
|
||||||
email: item.email || "",
|
email: item.email || "",
|
||||||
isActive: Boolean(item.isActive),
|
isActive: Boolean(item.isActive),
|
||||||
|
avatarColor: typeof item.avatarColor === "string" ? item.avatarColor.trim() : null,
|
||||||
lastLogin: item.lastLogin || null,
|
lastLogin: item.lastLogin || null,
|
||||||
createdAt: item.createdAt || null,
|
createdAt: item.createdAt || null,
|
||||||
updatedAt: item.updatedAt || 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"));
|
.sort((a, b) => a.username.localeCompare(b.username, "de-DE"));
|
||||||
setUsers(sorted);
|
setUsers(sorted);
|
||||||
@@ -99,7 +131,7 @@ export function Users() {
|
|||||||
const haystacks = [
|
const haystacks = [
|
||||||
user.username,
|
user.username,
|
||||||
user.email,
|
user.email,
|
||||||
...user.groups
|
...user.groups.map((group) => group.name)
|
||||||
].map((value) => String(value || "").toLowerCase());
|
].map((value) => String(value || "").toLowerCase());
|
||||||
return haystacks.some((value) => value.includes(query));
|
return haystacks.some((value) => value.includes(query));
|
||||||
});
|
});
|
||||||
@@ -165,48 +197,14 @@ export function Users() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<Card className="border border-blue-gray-100 shadow-sm">
|
<Card>
|
||||||
<CardHeader
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
floated={false}
|
<Typography
|
||||||
shadow={false}
|
variant="h6"
|
||||||
color="transparent"
|
color="white"
|
||||||
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
|
>
|
||||||
>
|
<span>Benutzerverwaltung</span>
|
||||||
<div>
|
</Typography>
|
||||||
<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>
|
</CardHeader>
|
||||||
<CardBody className="pt-0">
|
<CardBody className="pt-0">
|
||||||
{maintenanceActive && (
|
{maintenanceActive && (
|
||||||
@@ -215,8 +213,36 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
<div className="mb-8">
|
||||||
<div className="w-full md:max-w-md">
|
|
||||||
|
<div className="flex flex-wrap gap-2"> {/* <div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-blue-gray-400">
|
||||||
|
Einträge pro Seite
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={perPage}
|
||||||
|
onChange={handlePerPageChange}
|
||||||
|
className="w-full rounded-lg border border-blue-gray-100 px-3 py-2 text-sm text-blue-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:w-40"
|
||||||
|
>
|
||||||
|
{perPageOptions.map(({ value, label }) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="blue"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loading ? "Lädt ..." : "Aktualisieren"}
|
||||||
|
</Button>
|
||||||
|
</div> */}
|
||||||
|
{/* <div className="w-full md:max-w-md">
|
||||||
<Input
|
<Input
|
||||||
label="Suchen nach Name, E-Mail oder Gruppe"
|
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -224,8 +250,8 @@ export function Users() {
|
|||||||
disabled={loading && !users.length}
|
disabled={loading && !users.length}
|
||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
{searchQuery && (
|
{/* {searchQuery && (
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
color="blue-gray"
|
color="blue-gray"
|
||||||
@@ -234,9 +260,36 @@ export function Users() {
|
|||||||
>
|
>
|
||||||
Suche zurücksetzen
|
Suche zurücksetzen
|
||||||
</Button>
|
</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 && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
{error}
|
{error}
|
||||||
@@ -246,25 +299,27 @@ export function Users() {
|
|||||||
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||||
<table className="w-full min-w-[720px] table-auto text-left">
|
<table className="w-full min-w-[720px] table-auto text-left">
|
||||||
<thead>
|
<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">Benutzername</th>
|
||||||
<th className="px-6 py-4 font-semibold">E-Mail</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">Gruppen</th>
|
||||||
<th className="px-6 py-4 font-semibold">Status</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">Letzte Anmeldung</th>
|
||||||
<th className="px-6 py-4 font-semibold">Erstellt am</th>
|
<th className="px-6 py-4 font-semibold">Erstellt am</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Aktionen</th>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading && users.length === 0 ? (
|
{loading && users.length === 0 ? (
|
||||||
<tr>
|
<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 ...
|
Benutzerdaten werden geladen ...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : paginatedUsers.length === 0 ? (
|
) : paginatedUsers.length === 0 ? (
|
||||||
<tr>
|
<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.
|
Keine Benutzer gefunden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -272,18 +327,32 @@ export function Users() {
|
|||||||
paginatedUsers.map((user, index) => {
|
paginatedUsers.map((user, index) => {
|
||||||
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
|
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||||
return (
|
return (
|
||||||
<tr key={user.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
|
<tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||||
<td className="px-6 py-4 font-medium text-blue-gray-900">{user.username || "–"}</td>
|
<td className="px-6 py-4">
|
||||||
<td className="px-6 py-4">{user.email || "–"}</td>
|
<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">
|
<td className="px-6 py-4">
|
||||||
{user.groups.length > 0 ? (
|
{user.groups.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{user.groups.map((group) => (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-blue-gray-400">–</span>
|
<span className="text-stormGrey-400">–</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -294,8 +363,26 @@ export function Users() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">{formatTimestamp(user.lastLogin)}</td>
|
<td className="px-6 py-4">
|
||||||
<td className="px-6 py-4">{formatTimestamp(user.createdAt)}</td>
|
<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>
|
</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">
|
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Typography variant="small" color="gray">
|
<Typography variant="small" color="gray">
|
||||||
{!loading && users.length === 0
|
{""}
|
||||||
? "Noch keine Benutzer angelegt."
|
|
||||||
: `${filteredCount.toLocaleString("de-DE")} Benutzer gefunden`}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<PaginationControls disabled={loading && users.length === 0} />
|
<PaginationControls disabled={loading && users.length === 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,29 +10,29 @@ module.exports = withMT({
|
|||||||
green: colors.green,
|
green: colors.green,
|
||||||
slate: colors.slate,
|
slate: colors.slate,
|
||||||
orange: colors.orange, // falls du z. B. text-orange-300 brauchst
|
orange: colors.orange, // falls du z. B. text-orange-300 brauchst
|
||||||
auroraTeal: {50:'#f0fafb',100:'#e0f5f7',200:'#c3ecef',300:'#9de0e4',400:'#6ad3d8',500:'#38c6cc',600:'#2aa9b0',700:'#1f8d94',800:'#166e76',900:'#0c4f58'},
|
auroraTeal: { 50: '#f0fafb', 100: '#e0f5f7', 200: '#c3ecef', 300: '#9de0e4', 400: '#6ad3d8', 500: '#38c6cc', 600: '#2aa9b0', 700: '#1f8d94', 800: '#166e76', 900: '#0c4f58' },
|
||||||
sunsetCoral: {50:'#fff6f5',100:'#ffece9',200:'#ffd7d3',300:'#ffb8b5',400:'#ff9189',500:'#ff6a5d',600:'#e65048',700:'#b93d38',800:'#8a2c28',900:'#5c1c1a'},
|
sunsetCoral: { 50: '#fff6f5', 100: '#ffece9', 200: '#ffd7d3', 300: '#ffb8b5', 400: '#ff9189', 500: '#ff6a5d', 600: '#e65048', 700: '#b93d38', 800: '#8a2c28', 900: '#5c1c1a' },
|
||||||
lavenderSmoke: {50:'#faf8fc',100:'#f5f1f9',200:'#ece2f3',300:'#dac4e6',400:'#b88acf',500:'#9670b8',600:'#7a5894',700:'#5d4373',800:'#412f52',900:'#291e38'},
|
lavenderSmoke: { 50: '#faf8fc', 100: '#f5f1f9', 200: '#ece2f3', 300: '#dac4e6', 400: '#b88acf', 500: '#9670b8', 600: '#7a5894', 700: '#5d4373', 800: '#412f52', 900: '#291e38' },
|
||||||
mossGreen: {50:'#f5faf2',100:'#ebf5e5',200:'#d9ebca',300:'#b8d99e',400:'#8cc56f',500:'#60b240',600:'#4d8f33',700:'#3b6b27',800:'#2a491d',900:'#1c3012'},
|
mossGreen: { 50: '#f5faf2', 100: '#ebf5e5', 200: '#d9ebca', 300: '#b8d99e', 400: '#8cc56f', 500: '#60b240', 600: '#4d8f33', 700: '#3b6b27', 800: '#2a491d', 900: '#1c3012' },
|
||||||
deepOceanBlue: {50:'#f4faff',100:'#e9f5ff',200:'#cfe6ff',300:'#add1ff',400:'#7ab9ff',500:'#479fff',600:'#3c86e6',700:'#305eaf',800:'#23437f',900:'#162a50'},
|
deepOceanBlue: { 50: '#f4faff', 100: '#e9f5ff', 200: '#cfe6ff', 300: '#add1ff', 400: '#7ab9ff', 500: '#479fff', 600: '#3c86e6', 700: '#305eaf', 800: '#23437f', 900: '#162a50' },
|
||||||
warmAmberGlow: {50:'#fff9f2',100:'#fff3e5',200:'#ffe6cc',300:'#ffcc99',400:'#ffad4d',500:'#ff8f00',600:'#e67600',700:'#b35a00',800:'#804000',900:'#4d2600'},
|
warmAmberGlow: { 50: '#fff9f2', 100: '#fff3e5', 200: '#ffe6cc', 300: '#ffcc99', 400: '#ffad4d', 500: '#ff8f00', 600: '#e67600', 700: '#b35a00', 800: '#804000', 900: '#4d2600' },
|
||||||
peachMist: {50:'#fff8f7',100:'#fff0ed',200:'#ffe1db',300:'#ffc2b7',400:'#ff9a87',500:'#ff7257',600:'#e65f4b',700:'#b34938',800:'#803622',900:'#4d230f'},
|
peachMist: { 50: '#fff8f7', 100: '#fff0ed', 200: '#ffe1db', 300: '#ffc2b7', 400: '#ff9a87', 500: '#ff7257', 600: '#e65f4b', 700: '#b34938', 800: '#803622', 900: '#4d230f' },
|
||||||
slatePurple: {50:'#f7f5fa',100:'#efe9f5',200:'#ded1ea',300:'#bdb3d5',400:'#9584b5',500:'#6d5795',600:'#5c4a7c',700:'#473861',800:'#322648',900:'#1f1530'},
|
slatePurple: { 50: '#f7f5fa', 100: '#efe9f5', 200: '#ded1ea', 300: '#bdb3d5', 400: '#9584b5', 500: '#6d5795', 600: '#5c4a7c', 700: '#473861', 800: '#322648', 900: '#1f1530' },
|
||||||
mintTea: {50:'#f2fcfa',100:'#e5f9f6',200:'#ccefee',300:'#99dfdc',400:'#66cfc9',500:'#33bfb6',600:'#2aa395',700:'#228174',800:'#195953',900:'#103330'},
|
mintTea: { 50: '#f2fcfa', 100: '#e5f9f6', 200: '#ccefee', 300: '#99dfdc', 400: '#66cfc9', 500: '#33bfb6', 600: '#2aa395', 700: '#228174', 800: '#195953', 900: '#103330' },
|
||||||
copperRust: {50:'#faf7f6',100:'#f5efee',200:'#ebe0dc',300:'#d6c1b8',400:'#b28a86',500:'#8d4d55',600:'#7a444b',700:'#5f3338',800:'#442426',900:'#2d1718'},
|
copperRust: { 50: '#faf7f6', 100: '#f5efee', 200: '#ebe0dc', 300: '#d6c1b8', 400: '#b28a86', 500: '#8d4d55', 600: '#7a444b', 700: '#5f3338', 800: '#442426', 900: '#2d1718' },
|
||||||
// Neue 10
|
// Neue 10
|
||||||
arcticBlue: {50:'#f2f9fd',100:'#e6f3fb',200:'#cce6f7',300:'#99ceef',400:'#66b6e7',500:'#339ede',600:'#1f86c3',700:'#16699b',800:'#0f4b6f',900:'#082d43'},
|
arcticBlue: { 50: '#f2f9fd', 100: '#e6f3fb', 200: '#cce6f7', 300: '#99ceef', 400: '#66b6e7', 500: '#339ede', 600: '#1f86c3', 700: '#16699b', 800: '#0f4b6f', 900: '#082d43' },
|
||||||
roseQuartz: {50:'#fdf7f9',100:'#faeff3',200:'#f5dfe6',300:'#ecbfd0',400:'#e08dad',500:'#d45b89',600:'#b94771',700:'#8e3556',800:'#63233b',900:'#391324'},
|
roseQuartz: { 50: '#fdf7f9', 100: '#faeff3', 200: '#f5dfe6', 300: '#ecbfd0', 400: '#e08dad', 500: '#d45b89', 600: '#b94771', 700: '#8e3556', 800: '#63233b', 900: '#391324' },
|
||||||
citrusPunch: {50:'#fffdf6',100:'#fffbe9',200:'#fff5cc',300:'#ffeb99',400:'#ffe066',500:'#ffd633',600:'#e6bd00',700:'#b39300',800:'#806900',900:'#4d3f00'},
|
citrusPunch: { 50: '#fffdf6', 100: '#fffbe9', 200: '#fff5cc', 300: '#ffeb99', 400: '#ffe066', 500: '#ffd633', 600: '#e6bd00', 700: '#b39300', 800: '#806900', 900: '#4d3f00' },
|
||||||
midnightPlum: {50:'#faf7fb',100:'#f4ecf6',200:'#e4cfee',300:'#cba0de',400:'#a869cc',500:'#8431b9',600:'#6d299b',700:'#521f74',800:'#38154d',900:'#1f0b28'},
|
midnightPlum: { 50: '#faf7fb', 100: '#f4ecf6', 200: '#e4cfee', 300: '#cba0de', 400: '#a869cc', 500: '#8431b9', 600: '#6d299b', 700: '#521f74', 800: '#38154d', 900: '#1f0b28' },
|
||||||
sandstone: {50:'#fbf9f7',100:'#f6f2ee',200:'#ece3da',300:'#d8c6b3',400:'#c0a27f',500:'#a87e4b',600:'#906a3e',700:'#6b4f2f',800:'#47341f',900:'#241a10'},
|
sandstone: { 50: '#fbf9f7', 100: '#f6f2ee', 200: '#ece3da', 300: '#d8c6b3', 400: '#c0a27f', 500: '#a87e4b', 600: '#906a3e', 700: '#6b4f2f', 800: '#47341f', 900: '#241a10' },
|
||||||
emeraldMist: {50:'#f3faf7',100:'#e7f6ef',200:'#ccecdc',300:'#9ddabf',400:'#6ec8a3',500:'#3fb586',600:'#359771',700:'#2a765a',800:'#1f5442',900:'#13332a'},
|
emeraldMist: { 50: '#f3faf7', 100: '#e7f6ef', 200: '#ccecdc', 300: '#9ddabf', 400: '#6ec8a3', 500: '#3fb586', 600: '#359771', 700: '#2a765a', 800: '#1f5442', 900: '#13332a' },
|
||||||
velvetRed: {50:'#fdf7f7',100:'#faeeee',200:'#f4d6d6',300:'#e7a8a8',400:'#d56666',500:'#c32424',600:'#a61e1e',700:'#801717',800:'#590f0f',900:'#330808'},
|
velvetRed: { 50: '#fdf7f7', 100: '#faeeee', 200: '#f4d6d6', 300: '#e7a8a8', 400: '#d56666', 500: '#c32424', 600: '#a61e1e', 700: '#801717', 800: '#590f0f', 900: '#330808' },
|
||||||
stormGrey: {50:'#f7f8fa',100:'#eef0f3',200:'#d9dce2',300:'#b6bbc8',400:'#8b91a4',500:'#606780',600:'#4f556b',700:'#3e4456',800:'#2c313f',900:'#1b1e29'},
|
stormGrey: { 50: '#f7f8fa', 100: '#eef0f3', 200: '#d9dce2', 300: '#b6bbc8', 400: '#8b91a4', 500: '#606780', 600: '#4f556b', 700: '#3e4456', 800: '#2c313f', 900: '#1b1e29' },
|
||||||
neonLime: {50:'#f7ffe6',100:'#eeffc9',200:'#dbff96',300:'#c2ff52',400:'#a8ff0e',500:'#8de600',600:'#74bf00',700:'#5b9900',800:'#426f00',900:'#294600'},
|
neonLime: { 50: '#f7ffe6', 100: '#eeffc9', 200: '#dbff96', 300: '#c2ff52', 400: '#a8ff0e', 500: '#8de600', 600: '#74bf00', 700: '#5b9900', 800: '#426f00', 900: '#294600' },
|
||||||
skyMauve: {50:'#faf8fc',100:'#f4f0f9',200:'#e5dbf2',300:'#c9b2e6',400:'#a27dd4',500:'#7b48c2',600:'#633aa0',700:'#4b2b7c',800:'#341d58',900:'#1e1035'},
|
skyMauve: { 50: '#faf8fc', 100: '#f4f0f9', 200: '#e5dbf2', 300: '#c9b2e6', 400: '#a27dd4', 500: '#7b48c2', 600: '#633aa0', 700: '#4b2b7c', 800: '#341d58', 900: '#1e1035' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user