This commit is contained in:
root
2025-10-28 16:07:38 +00:00
parent e96cd63f33
commit 4eea4b23bd
16 changed files with 907 additions and 230 deletions
+4 -4
View File
@@ -3,7 +3,7 @@ import { db } from '../db/index.js';
export const SUPERUSER_GROUP_NAME = 'superuser'; export const SUPERUSER_GROUP_NAME = 'superuser';
const AVATAR_COLORS = [ export const AVATAR_COLORS = [
'bg-arcticBlue-600', 'bg-arcticBlue-600',
'bg-copperRust-500', 'bg-copperRust-500',
'bg-sunsetCoral-600', 'bg-sunsetCoral-600',
@@ -16,10 +16,10 @@ const AVATAR_COLORS = [
'bg-mossGreen-400' 'bg-mossGreen-400'
]; ];
const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500'; export const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]); const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]);
const pickRandomAvatarColor = () => { export const pickRandomAvatarColor = () => {
if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) { if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) {
return DEFAULT_AVATAR_COLOR; return DEFAULT_AVATAR_COLOR;
} }
@@ -27,7 +27,7 @@ const pickRandomAvatarColor = () => {
return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR; return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR;
}; };
const normalizeAvatarColor = (value) => { export const normalizeAvatarColor = (value) => {
if (!value) return null; if (!value) return null;
const candidate = String(value).trim(); const candidate = String(value).trim();
return AVATAR_COLOR_SET.has(candidate) ? candidate : null; return AVATAR_COLOR_SET.has(candidate) ? candidate : null;
+116 -1
View File
@@ -27,7 +27,7 @@ const createUsersTable = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE, email TEXT UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
password_salt TEXT, password_salt TEXT,
avatar_color TEXT, avatar_color TEXT,
@@ -59,6 +59,28 @@ CREATE TABLE IF NOT EXISTS user_group_memberships (
); );
`; `;
const createPermissionsTable = `
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`;
const createUserGroupPermissionsTable = `
CREATE TABLE IF NOT EXISTS user_group_permissions (
group_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, permission_id),
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
`;
const createServersTable = ` const createServersTable = `
CREATE TABLE IF NOT EXISTS servers ( CREATE TABLE IF NOT EXISTS servers (
@@ -84,6 +106,46 @@ CREATE TABLE IF NOT EXISTS endpoints (
); );
`; `;
const createUserServerPermissionOverridesTable = `
CREATE TABLE IF NOT EXISTS user_server_permission_overrides (
user_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, server_id, permission_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
`;
const createUserEndpointPermissionOverridesTable = `
CREATE TABLE IF NOT EXISTS user_endpoint_permission_overrides (
user_id INTEGER NOT NULL,
endpoint_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, endpoint_id, permission_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
`;
const createUserServerPermissionOverridesIndex = `
CREATE INDEX IF NOT EXISTS idx_user_server_permission_overrides_user_server
ON user_server_permission_overrides (user_id, server_id);
`;
const createUserEndpointPermissionOverridesIndex = `
CREATE INDEX IF NOT EXISTS idx_user_endpoint_permission_overrides_user_endpoint
ON user_endpoint_permission_overrides (user_id, endpoint_id);
`;
const createServerApiKeysTable = ` const createServerApiKeysTable = `
CREATE TABLE IF NOT EXISTS server_api_keys ( CREATE TABLE IF NOT EXISTS server_api_keys (
@@ -138,6 +200,44 @@ try {
db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT'); db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT');
console.log('️ avatar_color column hinzugefügt'); console.log('️ avatar_color column hinzugefügt');
} }
const emailColumn = userColumns.find((column) => column.name === 'email');
if (emailColumn && emailColumn.notnull === 1) {
try {
db.exec('PRAGMA foreign_keys = OFF;');
db.exec('DROP TABLE IF EXISTS users_backup;');
db.exec('ALTER TABLE users RENAME TO users_backup;');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT 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,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
INSERT INTO users (id, username, email, password_hash, password_salt, avatar_color, is_active, last_login, created_at, updated_at)
SELECT id, username, email, password_hash, password_salt, avatar_color, is_active, last_login, created_at, updated_at
FROM users_backup;
`);
db.exec('DROP TABLE IF EXISTS users_backup;');
console.log('️ users.email Spalte erlaubt jetzt NULL-Werte');
} catch (migrationError) {
console.error('⚠️ Umbau der users Tabelle fehlgeschlagen:', migrationError.message);
try {
db.exec('ALTER TABLE users_backup RENAME TO users;');
} catch (restoreError) {
console.error('⚠️ Konnte ursprüngliche users Tabelle nicht wiederherstellen:', restoreError.message);
}
} finally {
db.exec('PRAGMA foreign_keys = ON;');
}
}
} catch (err) { } catch (err) {
console.error('⚠️ Konnte avatar_color Spalte nicht prüfen/erstellen:', err.message); console.error('⚠️ Konnte avatar_color Spalte nicht prüfen/erstellen:', err.message);
} }
@@ -148,12 +248,27 @@ console.log('✅ user_groups table ready');
db.exec(createUserGroupMembershipsTable); db.exec(createUserGroupMembershipsTable);
console.log('✅ user_group_memberships table ready'); console.log('✅ user_group_memberships table ready');
db.exec(createPermissionsTable);
console.log('✅ permissions table ready');
db.exec(createUserGroupPermissionsTable);
console.log('✅ user_group_permissions table ready');
db.exec(createServersTable); db.exec(createServersTable);
console.log('✅ servers table ready'); console.log('✅ servers table ready');
db.exec(createEndpointsTable); db.exec(createEndpointsTable);
console.log('✅ endpoints table ready'); console.log('✅ endpoints table ready');
db.exec(createUserServerPermissionOverridesTable);
console.log('✅ user_server_permission_overrides table ready');
db.exec(createUserEndpointPermissionOverridesTable);
console.log('✅ user_endpoint_permission_overrides table ready');
db.exec(createUserServerPermissionOverridesIndex);
db.exec(createUserEndpointPermissionOverridesIndex);
db.exec(createServerApiKeysTable); db.exec(createServerApiKeysTable);
console.log('✅ server_api_keys table ready'); console.log('✅ server_api_keys table ready');
+50
View File
@@ -16,6 +16,34 @@ const selectGroupsWithMembers = db.prepare(`
ORDER BY g.name COLLATE NOCASE ORDER BY g.name COLLATE NOCASE
`); `);
const selectGroupWithMembersById = db.prepare(`
SELECT
g.id,
g.name,
g.description,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
FROM user_groups g
LEFT JOIN user_group_memberships m ON m.group_id = g.id
LEFT JOIN users u ON u.id = m.user_id
WHERE g.id = ?
GROUP BY g.id
`);
const selectGroupIdByName = db.prepare(`
SELECT id
FROM user_groups
WHERE lower(name) = lower(?)
LIMIT 1
`);
const insertGroupStatement = db.prepare(`
INSERT INTO user_groups (name, description, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const parseMembers = (rawPairs) => { const parseMembers = (rawPairs) => {
if (!rawPairs) { if (!rawPairs) {
return []; return [];
@@ -48,3 +76,25 @@ export function listGroups() {
const rows = selectGroupsWithMembers.all(); const rows = selectGroupsWithMembers.all();
return rows.map(sanitizeGroup); return rows.map(sanitizeGroup);
} }
export function createGroup({ name, description }) {
const normalizedName = typeof name === 'string' ? name.trim() : '';
if (!normalizedName) {
const error = new Error('GROUP_NAME_REQUIRED');
error.code = 'GROUP_NAME_REQUIRED';
throw error;
}
const existing = selectGroupIdByName.get(normalizedName);
if (existing) {
const error = new Error('GROUP_NAME_TAKEN');
error.code = 'GROUP_NAME_TAKEN';
throw error;
}
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null);
const groupId = Number(insertResult.lastInsertRowid);
const row = selectGroupWithMembersById.get(groupId);
return row ? sanitizeGroup(row) : null;
}
+46 -2
View File
@@ -45,8 +45,8 @@ import {
removeServer, removeServer,
setServerApiKey setServerApiKey
} from './setup/index.js'; } from './setup/index.js';
import { listUsers, getUserById, updateUserGroups } from './users/index.js'; import { listUsers, getUserById, updateUserGroups, createUser } from './users/index.js';
import { listGroups } from './groups/index.js'; import { listGroups, createGroup } from './groups/index.js';
dotenv.config(); dotenv.config();
@@ -1684,6 +1684,33 @@ app.get('/api/users', (req, res) => {
} }
}); });
app.post('/api/users', (req, res) => {
const { username, email, password, groupId, avatarColor } = req.body ?? {};
try {
const user = createUser({ username, email, password, groupId, avatarColor });
res.status(201).json({ item: user });
} catch (error) {
if (error.code === 'USERNAME_REQUIRED' || error.code === 'INVALID_GROUP_ID') {
return res.status(400).json({ error: error.code });
}
if (error.code === 'GROUP_NOT_FOUND') {
return res.status(400).json({ error: 'GROUP_NOT_FOUND' });
}
if (error.code === 'INVALID_EMAIL') {
return res.status(400).json({ error: 'INVALID_EMAIL' });
}
if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') {
return res.status(400).json({ error: error.code });
}
if (error.code === 'USERNAME_TAKEN' || error.code === 'EMAIL_TAKEN') {
return res.status(409).json({ error: error.code });
}
console.error('⚠️ [Users] Anlegen eines Benutzers fehlgeschlagen:', error);
res.status(500).json({ error: 'USER_CREATE_FAILED' });
}
});
app.get('/api/users/:userId', (req, res) => { app.get('/api/users/:userId', (req, res) => {
const { userId } = req.params; const { userId } = req.params;
const numericId = Number(userId); const numericId = Number(userId);
@@ -1741,6 +1768,23 @@ app.get('/api/groups', (req, res) => {
} }
}); });
app.post('/api/groups', (req, res) => {
const { name, description } = req.body ?? {};
try {
const group = createGroup({ name, description });
res.status(201).json({ item: group });
} catch (error) {
if (error.code === 'GROUP_NAME_REQUIRED') {
return res.status(400).json({ error: 'GROUP_NAME_REQUIRED' });
}
if (error.code === 'GROUP_NAME_TAKEN') {
return res.status(409).json({ error: 'GROUP_NAME_TAKEN' });
}
console.error('⚠️ [Groups] Anlegen der Benutzergruppe fehlgeschlagen:', error);
res.status(500).json({ error: 'GROUP_CREATE_FAILED' });
}
});
// Superuser Setup // Superuser Setup
app.get('/api/auth/superuser/status', (req, res) => { app.get('/api/auth/superuser/status', (req, res) => {
const exists = hasSuperuser(); const exists = hasSuperuser();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -34,8 +34,8 @@
<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-ec92bae1.js"></script> <script type="module" crossorigin src="/assets/index-665b980d.js"></script>
<link rel="stylesheet" href="/assets/index-ecd5fb94.css"> <link rel="stylesheet" href="/assets/index-5f261614.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+106 -1
View File
@@ -1,4 +1,10 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import {
hashPassword,
normalizeAvatarColor,
pickRandomAvatarColor,
DEFAULT_AVATAR_COLOR
} from '../auth/superuser.js';
const selectUsersWithGroups = db.prepare(` const selectUsersWithGroups = db.prepare(`
SELECT SELECT
@@ -36,6 +42,9 @@ const selectUserWithGroupsById = db.prepare(`
GROUP BY u.id GROUP BY u.id
`); `);
const selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?');
const deleteMembershipsByUser = db.prepare(` const deleteMembershipsByUser = db.prepare(`
DELETE FROM user_group_memberships DELETE FROM user_group_memberships
WHERE user_id = ? WHERE user_id = ?
@@ -48,6 +57,11 @@ const insertMembershipForUser = db.prepare(`
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?'); const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
const insertUserStatement = db.prepare(`
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const parseGroupPairs = (rawPairs) => { const parseGroupPairs = (rawPairs) => {
if (!rawPairs) { if (!rawPairs) {
return []; return [];
@@ -72,7 +86,7 @@ const parseGroupPairs = (rawPairs) => {
const sanitizeUserRecord = (row) => ({ const sanitizeUserRecord = (row) => ({
id: row.id, id: row.id,
username: row.username, username: row.username,
email: row.email, email: row.email || null,
isActive: Boolean(row.is_active), isActive: Boolean(row.is_active),
avatarColor: row.avatar_color || null, avatarColor: row.avatar_color || null,
lastLogin: row.last_login || null, lastLogin: row.last_login || null,
@@ -98,6 +112,97 @@ const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
}); });
}); });
const insertUserWithGroups = db.transaction(({ username, email, passwordHash, passwordSalt, avatarColor, groupId }) => {
const result = insertUserStatement.run(username, email, passwordHash, passwordSalt, avatarColor);
const userId = Number(result.lastInsertRowid);
applyUserGroupAssignments(userId, [groupId]);
return userId;
});
const normalizeEmail = (value) => {
if (!value) return null;
const trimmed = String(value).trim();
if (!trimmed) {
return null;
}
return trimmed.toLowerCase();
};
export function createUser({ username, email, password, groupId, avatarColor }) {
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const numericGroupId = Number(groupId);
if (!Number.isFinite(numericGroupId) || numericGroupId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const groupRow = selectGroupById.get(numericGroupId);
if (!groupRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
const normalizedEmail = normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL');
error.code = 'INVALID_EMAIL';
throw error;
}
const existingUsername = selectUserByUsername.get(normalizedUsername);
if (existingUsername) {
const error = new Error('USERNAME_TAKEN');
error.code = 'USERNAME_TAKEN';
throw error;
}
if (normalizedEmail) {
const existingEmail = selectUserByEmail.get(normalizedEmail);
if (existingEmail) {
const error = new Error('EMAIL_TAKEN');
error.code = 'EMAIL_TAKEN';
throw error;
}
}
let passwordHash;
let passwordSalt;
try {
const hashed = hashPassword(password);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
} catch (err) {
if (err && err.code) {
throw err;
}
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
const userId = insertUserWithGroups({
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
passwordSalt,
avatarColor: avatarColorToPersist,
groupId: numericGroupId
});
return getUserById(userId);
}
export function updateUserGroups(userId, groupIds) { export function updateUserGroups(userId, groupIds) {
const numericUserId = Number(userId); const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) { if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { renderToString } from 'react-dom/server';
import pkg from '@material-tailwind/react';
const { Select, Option } = pkg;
const element = React.createElement(Select, {
label: 'Test',
value: 'b',
selected: (el) => el?.props.children,
onChange: () => {},
children: [
React.createElement(Option, { key: 'a', value: 'a' }, 'Alpha'),
React.createElement(Option, { key: 'b', value: 'b' }, 'Beta')
]
});
console.log(renderToString(element));
+14
View File
@@ -0,0 +1,14 @@
export 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"
];
export const DEFAULT_AVATAR_COLOR = "bg-mossGreen-500";
+76 -120
View File
@@ -9,18 +9,13 @@ import {
Button, Button,
Spinner, Spinner,
CardFooter, CardFooter,
Tabs,
TabsHeader,
Tab,
Switch, Switch,
Tooltip, Tooltip,
Avatar, Avatar,
Checkbox Select,
Option
} from "@material-tailwind/react"; } from "@material-tailwind/react";
import { import {
HomeIcon,
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
PencilIcon, PencilIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -28,22 +23,9 @@ import { Link } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx"; import { useToast } from "@/components/ToastProvider.jsx";
import { ProfileInfoCard } from "@/widgets/cards"; import { ProfileInfoCard } from "@/widgets/cards";
import { platformSettingsData, projectsData } from "@/data"; import { platformSettingsData, projectsData } from "@/data";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx"; 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 _ = AVATAR_COLORS.join(" ");
@@ -97,8 +79,8 @@ export function UserDetails() {
const [availableGroups, setAvailableGroups] = useState([]); const [availableGroups, setAvailableGroups] = useState([]);
const [groupsLoading, setGroupsLoading] = useState(false); const [groupsLoading, setGroupsLoading] = useState(false);
const [groupsError, setGroupsError] = useState(""); const [groupsError, setGroupsError] = useState("");
const [selectedGroupIds, setSelectedGroupIds] = useState([]); const [selectedGroupId, setSelectedGroupId] = useState(null);
const [savingGroups, setSavingGroups] = useState(false); const [savingGroup, setSavingGroup] = useState(false);
const maintenanceActive = Boolean(maintenance?.active); const maintenanceActive = Boolean(maintenance?.active);
@@ -203,109 +185,95 @@ export function UserDetails() {
fetchAvailableGroups(); fetchAvailableGroups();
}, [fetchAvailableGroups]); }, [fetchAvailableGroups]);
const originalGroupIds = useMemo(() => { const originalGroupId = useMemo(() => {
if (!user || !Array.isArray(user.groups)) { if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
return []; return null;
} }
return user.groups const firstValid = user.groups
.map((group) => Number(group.id)) .map((group) => Number(group.id))
.filter((id) => Number.isFinite(id) && id > 0); .find((id) => Number.isFinite(id) && id > 0);
return Number.isFinite(firstValid) ? firstValid : null;
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
setSelectedGroupIds([...originalGroupIds]); setSelectedGroupId(originalGroupId);
}, [originalGroupIds]); }, [originalGroupId]);
const hasGroupChanges = useMemo(() => { const hasGroupChanges = useMemo(() => {
const normalize = (ids) => if (!hasLoaded) {
Array.from( return false;
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 originalGroupId !== selectedGroupId;
}, [originalGroupId, selectedGroupId, hasLoaded]);
return next.some((value, index) => value !== baseline[index]); const handleGroupChange = useCallback((value) => {
}, [selectedGroupIds, originalGroupIds]); if (!value) {
setSelectedGroupId(null);
const handleToggleGroup = useCallback((groupId) => {
const numeric = Number(groupId);
if (!Number.isFinite(numeric) || numeric <= 0) {
return; return;
} }
setSelectedGroupIds((prev) => { const numeric = Number(value);
if (prev.includes(numeric)) { setSelectedGroupId(Number.isFinite(numeric) && numeric > 0 ? numeric : null);
return prev.filter((id) => id !== numeric);
}
return [...prev, numeric];
});
}, []); }, []);
const handleResetGroups = useCallback(() => { const handleResetGroup = useCallback(() => {
setSelectedGroupIds([...originalGroupIds]); setSelectedGroupId(originalGroupId);
}, [originalGroupIds]); }, [originalGroupId]);
const handleSaveGroups = useCallback(async () => { const handleSaveGroup = useCallback(async () => {
if (!user) { if (!user || !hasGroupChanges) {
return; return;
} }
const payloadGroups = Array.from( const payloadGroups = selectedGroupId ? [selectedGroupId] : [];
new Set(
selectedGroupIds
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0)
)
);
setSavingGroups(true); setSavingGroup(true);
setGroupsError(""); setGroupsError("");
try { try {
const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups }); const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups });
const updatedUser = mapUser(response.data?.item || response.data?.user); const updatedUser = mapUser(response.data?.item || response.data?.user);
setUser(updatedUser); setUser(updatedUser);
setSelectedGroupIds( const nextGroupId = Array.isArray(updatedUser.groups) && updatedUser.groups.length > 0
Array.isArray(updatedUser.groups) ? Number(updatedUser.groups[0].id)
? updatedUser.groups : null;
.map((group) => Number(group.id)) setSelectedGroupId(Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null);
.filter((id) => Number.isFinite(id) && id > 0)
: []
);
showToast({ showToast({
variant: "success", variant: "success",
title: "Benutzergruppen aktualisiert", title: "Globale Rolle aktualisiert",
description: "Die Gruppenzuordnung wurde gespeichert." description: "Die Gruppenzuordnung wurde gespeichert."
}); });
} catch (err) { } catch (err) {
const serverError = err.response?.data?.error; const serverError = err.response?.data?.error;
let message = err.message || "Benutzergruppen konnten nicht aktualisiert werden."; let message = err.message || "Die globale Rolle konnte nicht aktualisiert werden.";
if (serverError === "INVALID_USER_ID") { if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig."; message = "Die Benutzer-ID ist ungültig.";
} else if (serverError === "USER_NOT_FOUND") { } else if (serverError === "USER_NOT_FOUND") {
message = "Der Benutzer konnte nicht gefunden werden."; message = "Der Benutzer konnte nicht gefunden werden.";
} else if (serverError === "GROUP_NOT_FOUND") { } else if (serverError === "GROUP_NOT_FOUND") {
message = "Mindestens eine ausgewählte Gruppe existiert nicht mehr."; message = "Die ausgewählte Gruppe existiert nicht mehr.";
} else if (typeof serverError === "string" && serverError.length > 0) { } else if (typeof serverError === "string" && serverError.length > 0) {
message = serverError; message = serverError;
} }
setGroupsError(message); setGroupsError(message);
showToast({ showToast({
variant: "error", variant: "error",
title: "Benutzergruppen", title: "Globale Rolle",
description: message description: message
}); });
} finally { } finally {
setSavingGroups(false); setSavingGroup(false);
} }
}, [user, selectedGroupIds, showToast]); }, [user, selectedGroupId, hasGroupChanges, showToast]);
const selectedGroupLabel = useMemo(() => {
if (!selectedGroupId) {
return null;
}
const match = availableGroups.find((group) => group.id === selectedGroupId);
return match ? match.name : null;
}, [availableGroups, selectedGroupId]);
const selectValue = selectedGroupId ? String(selectedGroupId) : "";
const selectDisabled = maintenanceActive || savingGroup || !user || groupsLoading;
const avatarLabel = useMemo(() => { const avatarLabel = useMemo(() => {
const source = (user?.username || user?.email || "").trim(); const source = (user?.username || user?.email || "").trim();
@@ -406,7 +374,7 @@ export function UserDetails() {
<div id="platform" className="flex flex-col gap-4"> <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"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Typography variant="h6" color="blue-gray"> <Typography variant="h6" color="blue-gray">
Benutzergruppen Globale Rolle
</Typography> </Typography>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -414,8 +382,8 @@ export function UserDetails() {
size="sm" size="sm"
color="blue-gray" color="blue-gray"
className="normal-case" className="normal-case"
onClick={handleResetGroups} onClick={handleResetGroup}
disabled={!hasGroupChanges || savingGroups || !user} disabled={!hasGroupChanges || savingGroup || !user}
> >
Änderungen verwerfen Änderungen verwerfen
</Button> </Button>
@@ -423,10 +391,10 @@ export function UserDetails() {
size="sm" size="sm"
color="green" color="green"
className="normal-case" className="normal-case"
onClick={handleSaveGroups} onClick={handleSaveGroup}
disabled={maintenanceActive || savingGroups || !hasGroupChanges || !user} disabled={maintenanceActive || savingGroup || !hasGroupChanges || !user}
> >
{savingGroups ? "Speichert ..." : "Speichern"} {savingGroup ? "Speichert ..." : "Speichern"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -450,42 +418,30 @@ export function UserDetails() {
Es sind noch keine Benutzergruppen vorhanden. Es sind noch keine Benutzergruppen vorhanden.
</Typography> </Typography>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
{availableGroups.map((group) => { <div className="sm:flex-1">
const checked = selectedGroupIds.includes(group.id); <Select
return ( label="Benutzergruppe wählen"
<label value={selectValue}
key={group.id} onChange={handleGroupChange}
className={`flex items-start justify-between gap-3 rounded-lg border px-4 py-3 transition ${ disabled={selectDisabled}
checked ? "border-auroraTeal-500 bg-auroraTeal-500/10" : "border-blue-gray-100 bg-white" variant="outlined"
} ${maintenanceActive ? "opacity-70" : ""}`}
> >
<div className="flex items-start gap-3"> {availableGroups.map((group) => (
<Checkbox <Option key={group.id} value={String(group.id)}>
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} {group.name}
</Typography> </Option>
{group.description && ( ))}
<Typography variant="small" className="text-xs text-stormGrey-500"> </Select>
{group.description} </div>
</Typography> </div>
)} )}
</div> {!(groupsLoading && availableGroups.length === 0) && (
</div> <Typography variant="small" className="text-sm text-stormGrey-500">
<Typography variant="small" className="text-xs font-medium text-stormGrey-500"> {selectedGroupLabel
Mitglieder: {group.memberCount} ? `Aktuell zugewiesene Gruppe: ${selectedGroupLabel}`
: "Dem Benutzer ist aktuell keine Gruppe zugewiesen."}
</Typography> </Typography>
</label>
);
})}
</div>
)} )}
</div> </div>
</div> </div>
+102 -2
View File
@@ -9,8 +9,7 @@ import {
Button, Button,
Select, Select,
Option, Option,
Input, 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";
@@ -21,6 +20,10 @@ export function Usergroups() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDescription, setNewGroupDescription] = useState("");
const [createGroupError, setCreateGroupError] = useState("");
const [creatingGroup, setCreatingGroup] = useState(false);
const { showToast } = useToast(); const { showToast } = useToast();
const { maintenance } = useMaintenance(); const { maintenance } = useMaintenance();
@@ -171,6 +174,65 @@ export function Usergroups() {
}).format(parsed); }).format(parsed);
}, []); }, []);
const resetNewGroupForm = useCallback(() => {
setNewGroupName("");
setNewGroupDescription("");
setCreateGroupError("");
}, []);
const handleCreateGroup = useCallback(async () => {
if (maintenanceActive) {
return;
}
const trimmedName = newGroupName.trim();
if (!trimmedName) {
setCreateGroupError("Gruppenname ist erforderlich.");
return;
}
setCreatingGroup(true);
setCreateGroupError("");
try {
const payload = { name: trimmedName };
const trimmedDescription = newGroupDescription.trim();
if (trimmedDescription) {
payload.description = trimmedDescription;
}
const response = await axios.post("/api/groups", payload);
showToast({
variant: "success",
title: "Gruppe angelegt",
description: `Die Benutzergruppe "${response.data?.item?.name ?? trimmedName}" wurde erstellt.`
});
resetNewGroupForm();
fetchGroups();
} catch (err) {
const errorCode = err.response?.data?.error;
let message = "Benutzergruppe konnte nicht angelegt werden.";
if (errorCode === "GROUP_NAME_REQUIRED") {
message = "Gruppenname ist erforderlich.";
} else if (errorCode === "GROUP_NAME_TAKEN") {
message = "Der Gruppenname wird bereits verwendet.";
}
setCreateGroupError(message);
showToast({
variant: "error",
title: "Erstellen fehlgeschlagen",
description: message
});
} finally {
setCreatingGroup(false);
}
}, [
maintenanceActive,
newGroupName,
newGroupDescription,
showToast,
resetNewGroupForm,
fetchGroups
]);
const createGroupDisabled = maintenanceActive || creatingGroup;
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">
@@ -190,6 +252,44 @@ export function Usergroups() {
</div> </div>
)} )}
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neue Benutzergruppe anlegen
</Typography>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Der Gruppenname ist Pflicht, die Beschreibung optional.
</Typography>
{createGroupError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createGroupError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Gruppenname"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
<Input
label="Beschreibung (optional)"
value={newGroupDescription}
onChange={(event) => setNewGroupDescription(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
Formular zurücksetzen
</Button>
</div>
</div>
<div className="mb-8"> <div className="mb-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="md:flex-1"> <div className="md:flex-1">
+269 -1
View File
@@ -15,6 +15,7 @@ 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";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
const normalizeUserGroups = (rawGroups) => { const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) { if (!Array.isArray(rawGroups)) {
@@ -47,6 +48,16 @@ export function Users() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [availableGroups, setAvailableGroups] = useState([]);
const [groupsLoading, setGroupsLoading] = useState(false);
const [groupsError, setGroupsError] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newGroupId, setNewGroupId] = useState("");
const [newAvatarColor, setNewAvatarColor] = useState("");
const [createError, setCreateError] = useState("");
const [creatingUser, setCreatingUser] = useState(false);
const { showToast } = useToast(); const { showToast } = useToast();
const { maintenance } = useMaintenance(); const { maintenance } = useMaintenance();
@@ -100,13 +111,45 @@ export function Users() {
} }
}, [showToast]); }, [showToast]);
const fetchGroups = 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 || ""
}))
.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(() => { useEffect(() => {
fetchUsers(); fetchUsers();
}, [fetchUsers]); }, [fetchUsers]);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
fetchUsers(); fetchUsers();
}, [fetchUsers]); fetchGroups();
}, [fetchUsers, fetchGroups]);
const handleSearchChange = useCallback((event) => { const handleSearchChange = useCallback((event) => {
setSearchQuery(event.target.value); setSearchQuery(event.target.value);
@@ -195,6 +238,140 @@ export function Users() {
}).format(parsed); }).format(parsed);
}, []); }, []);
const resetNewUserForm = useCallback(() => {
setNewUsername("");
setNewEmail("");
setNewPassword("");
setNewGroupId("");
setNewAvatarColor("");
setCreateError("");
}, []);
const handleCreateUser = useCallback(async () => {
if (maintenanceActive) {
return;
}
const trimmedUsername = newUsername.trim();
if (!trimmedUsername) {
setCreateError("Benutzername ist erforderlich.");
return;
}
const sanitizedPassword = typeof newPassword === "string" ? newPassword.trim() : "";
if (sanitizedPassword.length < 8) {
setCreateError("Passwort muss mindestens 8 Zeichen enthalten.");
return;
}
if (!newGroupId) {
setCreateError("Bitte eine globale Rolle auswählen.");
return;
}
setCreatingUser(true);
setCreateError("");
try {
const payload = {
username: trimmedUsername,
password: sanitizedPassword,
groupId: Number(newGroupId)
};
const trimmedEmail = newEmail.trim();
if (trimmedEmail) {
payload.email = trimmedEmail;
}
if (newAvatarColor) {
payload.avatarColor = newAvatarColor;
}
const response = await axios.post("/api/users", payload);
resetNewUserForm();
fetchUsers();
showToast({
variant: "success",
title: "Benutzer angelegt",
description: `Der Benutzer "${response.data?.item?.username ?? trimmedUsername}" wurde erstellt.`
});
} catch (err) {
const errorCode = err.response?.data?.error;
let message = "Benutzer konnte nicht angelegt werden.";
switch (errorCode) {
case "USERNAME_REQUIRED":
message = "Benutzername ist erforderlich.";
break;
case "INVALID_EMAIL":
message = "Bitte eine gültige E-Mail-Adresse angeben.";
break;
case "INVALID_PASSWORD":
case "PASSWORD_TOO_SHORT":
message = "Passwort muss mindestens 8 Zeichen enthalten.";
break;
case "INVALID_GROUP_ID":
case "GROUP_NOT_FOUND":
message = "Bitte eine gültige globale Rolle auswählen.";
break;
case "USERNAME_TAKEN":
message = "Benutzername wird bereits verwendet.";
break;
case "EMAIL_TAKEN":
message = "E-Mail-Adresse wird bereits verwendet.";
break;
default:
break;
}
setCreateError(message);
showToast({
variant: "error",
title: "Erstellen fehlgeschlagen",
description: message
});
} finally {
setCreatingUser(false);
}
}, [
maintenanceActive,
newUsername,
newEmail,
newPassword,
newGroupId,
newAvatarColor,
showToast,
resetNewUserForm,
fetchUsers
]);
const createDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0;
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0;
const avatarSelectDisabled = maintenanceActive || creatingUser;
const renderSelectedGroup = useCallback(
(element) => {
if (element && element.props && element.props.children) {
return element.props.children;
}
if (!newGroupId) {
return "Bitte auswählen";
}
const selected = availableGroups.find((group) => String(group.id) === newGroupId);
return selected ? selected.name : "Bitte auswählen";
},
[availableGroups, newGroupId]
);
const renderSelectedAvatar = useCallback(
(element) => {
if (element && element.props && element.props.children) {
return element.props.children;
}
if (!newAvatarColor) {
return "Automatisch (zufällig)";
}
return (
<span className="flex items-center gap-2">
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${newAvatarColor}`} />
<span className="text-xs">{newAvatarColor}</span>
</span>
);
},
[newAvatarColor]
);
return ( return (
<div className="mt-12"> <div className="mt-12">
<Card> <Card>
@@ -213,6 +390,97 @@ export function Users() {
</div> </div>
)} )}
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Benutzer anlegen
</Typography>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
</Typography>
{createError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createError}
</div>
)}
{groupsError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Benutzername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
label="E-Mail (optional)"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
type="password"
label="Passwort"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Select
label="Globale Rolle"
variant="outlined"
value={newGroupId}
onChange={setNewGroupId}
disabled={groupSelectDisabled}
selected={renderSelectedGroup}
>
<Option value="">Bitte auswählen</Option>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
<Select
label="Avatar-Farbe"
variant="outlined"
value={newAvatarColor}
onChange={setNewAvatarColor}
disabled={avatarSelectDisabled}
selected={renderSelectedAvatar}
>
<Option value="">Automatisch (zufällig)</Option>
{AVATAR_COLORS.map((color) => (
<Option key={color} value={color}>
<span className="flex items-center gap-2">
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
<span className="text-xs">{color}</span>
</span>
</Option>
))}
</Select>
</div>
<div className="mt-4 text-sm text-stormGrey-500">
{groupsLoading
? "Benutzergruppen werden geladen ..."
: availableGroups.length === 0
? "Noch keine Benutzergruppen vorhanden. Bitte legen Sie zuerst eine globale Rolle an."
: ""}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
Formular zurücksetzen
</Button>
</div>
</div>
<div className="mb-8"> <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 className="flex flex-wrap gap-2"> {/* <div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
+8
View File
@@ -0,0 +1,8 @@
const React = require('react');
const { renderToString } = require('react-dom/server');
const { Select, Option } = require('@material-tailwind/react');
const element = React.createElement(Select, { value: 'a', onChange: ()=>{} },
React.createElement(Option, { value: 'a' }, 'Alpha'),
React.createElement(Option, { value: 'b' }, 'Beta')
);
console.log(renderToString(element));