User, Groups, Logs Update

This commit is contained in:
root
2025-10-29 15:38:25 +00:00
parent 4eea4b23bd
commit 36a6e2b66c
25 changed files with 3623 additions and 1724 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+129 -19
View File
@@ -2,16 +2,27 @@ import { db } from './index.js';
db.exec('PRAGMA foreign_keys = ON;');
const createRedeployLogsTable = `
CREATE TABLE IF NOT EXISTS redeploy_logs (
const createEventLogsTable = `
CREATE TABLE IF NOT EXISTS event_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
stack_id TEXT NOT NULL,
stack_name TEXT NOT NULL,
status TEXT NOT NULL,
category TEXT NOT NULL,
event_type TEXT,
action TEXT,
status TEXT,
severity TEXT,
entity_type TEXT,
entity_id TEXT,
entity_name TEXT,
actor_type TEXT,
actor_id TEXT,
actor_name TEXT,
source TEXT,
context_type TEXT,
context_id TEXT,
context_label TEXT,
message TEXT,
endpoint INTEGER,
redeploy_type TEXT
metadata TEXT
);
`;
@@ -43,6 +54,7 @@ CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
avatar_color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -172,20 +184,112 @@ CREATE TABLE IF NOT EXISTS user_settings (
);
`;
db.exec(createRedeployLogsTable);
const tableExists = (tableName) => {
return Boolean(
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
.get(tableName)
);
};
try {
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all();
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type');
if (!hasRedeployType) {
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
console.log('️ redeploy_type column hinzugefügt');
const serializeMetadata = (payload) => {
if (!payload || typeof payload !== 'object' || !Object.keys(payload).length) {
return null;
}
} catch (err) {
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message);
}
try {
return JSON.stringify(payload);
} catch (err) {
console.error('⚠️ Konnte Migrations-Metadaten nicht serialisieren:', err.message);
return null;
}
};
console.log('✅ redeploy_logs table ready');
db.exec(createEventLogsTable);
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_timestamp ON event_logs (timestamp DESC);');
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_category ON event_logs (category);');
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_event_type ON event_logs (event_type);');
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_entity ON event_logs (entity_type, entity_id);');
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_context ON event_logs (context_type, context_id);');
console.log('✅ event_logs table ready');
if (tableExists('redeploy_logs')) {
try {
const legacyRows = db.prepare(`
SELECT id, timestamp, stack_id, stack_name, status, message, endpoint, redeploy_type
FROM redeploy_logs
ORDER BY id ASC
`).all();
if (legacyRows.length) {
const insertEventLog = db.prepare(`
INSERT INTO event_logs (
id,
timestamp,
category,
event_type,
action,
status,
entity_type,
entity_id,
entity_name,
context_type,
context_id,
message,
metadata
) VALUES (
@id,
@timestamp,
'stack',
@eventType,
'redeploy',
@status,
'stack',
@entityId,
@entityName,
@contextType,
@contextId,
@message,
@metadata
)
`);
const migrate = db.transaction((rows) => {
rows.forEach((row) => {
const contextType = row.endpoint !== null && row.endpoint !== undefined ? 'endpoint' : null;
const contextId = contextType ? String(row.endpoint) : null;
const metadataPayload = {
origin: 'redeploy_logs'
};
if (row.redeploy_type) {
metadataPayload.redeployType = row.redeploy_type;
}
if (contextId) {
metadataPayload.endpointId = contextId;
}
insertEventLog.run({
id: row.id,
timestamp: row.timestamp,
eventType: row.redeploy_type ?? null,
status: row.status ?? null,
entityId: row.stack_id !== undefined && row.stack_id !== null ? String(row.stack_id) : null,
entityName: row.stack_name ?? null,
contextType,
contextId,
message: row.message ?? null,
metadata: serializeMetadata(metadataPayload)
});
});
});
migrate(legacyRows);
console.log(`${legacyRows.length} Einträge aus redeploy_logs migriert`);
}
db.exec('DROP TABLE redeploy_logs;');
console.log('️ Alte Tabelle redeploy_logs entfernt');
} catch (err) {
console.error('⚠️ Migration von redeploy_logs fehlgeschlagen:', err.message);
}
}
db.exec(createSettingsTable);
console.log('✅ settings table ready');
@@ -198,7 +302,13 @@ try {
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');
console.log('️ avatar_color column in Tabelle users hinzugefügt');
}
const userGroupColumns = db.prepare('PRAGMA table_info(user_groups)').all();
const hasAvatarColorGroup = userGroupColumns.some((column) => column.name === 'avatar_color');
if (!hasAvatarColorGroup) {
db.exec('ALTER TABLE user_groups ADD COLUMN avatar_color TEXT');
console.log('️ avatar_color column in Tabelle user_groups hinzugefügt');
}
const emailColumn = userColumns.find((column) => column.name === 'email');
if (emailColumn && emailColumn.notnull === 1) {
-181
View File
@@ -1,181 +0,0 @@
import { db } from './index.js';
const valueToArray = (value) => {
if (!value && value !== 0) return [];
const base = Array.isArray(value) ? value : [value];
return base
.flatMap((entry) => String(entry).split(','))
.map((entry) => entry.trim())
.filter(Boolean);
};
const singleValue = (value) => {
if (value === undefined || value === null) return undefined;
return Array.isArray(value) ? value[0] : value;
};
export function buildLogFilter(queryParams = {}) {
const filters = [];
const params = {};
const ids = valueToArray(queryParams.ids ?? queryParams.id)
.map((entry) => Number(entry))
.filter((value) => !Number.isNaN(value));
if (ids.length) {
const placeholders = ids.map((_, idx) => `@id${idx}`);
filters.push(`id IN (${placeholders.join(', ')})`);
ids.forEach((value, idx) => {
params[`id${idx}`] = value;
});
}
const stackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
if (stackIds.length) {
const placeholders = stackIds.map((_, idx) => `@stackId${idx}`);
filters.push(`stack_id IN (${placeholders.join(', ')})`);
stackIds.forEach((stack, idx) => {
params[`stackId${idx}`] = stack;
});
}
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
if (statuses.length) {
const placeholders = statuses.map((_, idx) => `@status${idx}`);
filters.push(`status IN (${placeholders.join(', ')})`);
statuses.forEach((entry, idx) => {
params[`status${idx}`] = entry;
});
}
const endpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
if (endpoints.length) {
const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`);
filters.push(`endpoint IN (${placeholders.join(', ')})`);
endpoints.forEach((entry, idx) => {
const numeric = Number(entry);
params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric;
});
}
const redeployTypes = valueToArray(queryParams.redeployTypes ?? queryParams.redeployType);
if (redeployTypes.length) {
const placeholders = redeployTypes.map((_, idx) => `@redeployType${idx}`);
filters.push(`redeploy_type IN (${placeholders.join(', ')})`);
redeployTypes.forEach((entry, idx) => {
params[`redeployType${idx}`] = entry;
});
}
const messageQuery = singleValue(queryParams.message);
if (messageQuery && String(messageQuery).trim()) {
filters.push('message LIKE @message');
params.message = `%${String(messageQuery).trim()}%`;
}
const from = singleValue(queryParams.from);
if (from) {
filters.push('timestamp >= @from');
params.from = from;
}
const to = singleValue(queryParams.to);
if (to) {
filters.push('timestamp <= @to');
params.to = to;
}
return {
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
params,
};
}
const insertRedeployLogStmt = db.prepare(`
INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint, redeploy_type)
VALUES (@stackId, @stackName, @status, @message, @endpoint, @redeployType)
`);
export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null, redeployType = null }) {
try {
insertRedeployLogStmt.run({
stackId: String(stackId),
stackName: stackName ?? 'Unknown',
status,
message,
endpoint,
redeployType: redeployType ?? null
});
} catch (err) {
console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message);
}
}
export function deleteLogById(id) {
const stmt = db.prepare('DELETE FROM redeploy_logs WHERE id = ?');
const info = stmt.run(id);
return info.changes || 0;
}
export function deleteLogsByFilters(queryParams = {}) {
const { whereClause, params } = buildLogFilter(queryParams);
const stmt = db.prepare(`DELETE FROM redeploy_logs ${whereClause}`);
const info = stmt.run(params);
return info.changes || 0;
}
export function exportLogsByFilters(queryParams = {}, format = 'txt') {
const { whereClause, params } = buildLogFilter(queryParams);
const rows = db.prepare(`
SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType
FROM redeploy_logs
${whereClause}
ORDER BY datetime(timestamp) DESC
`).all(params);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
if (format === 'sql') {
const statements = rows.map((row) => {
const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint', 'redeploy_type'];
const values = [
row.id,
row.timestamp,
row.stackId,
row.stackName,
row.status,
row.message,
row.endpoint,
row.redeployType
].map((value) => {
if (value === null || value === undefined) return 'NULL';
return `'${String(value).replace(/'/g, "''")}'`;
});
return `INSERT INTO redeploy_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
});
return {
filename: `redeploy-logs-${timestamp}.sql`,
contentType: 'application/sql; charset=utf-8',
content: statements.join('\n')
};
}
const lines = rows.map((row) => {
const parts = [
`[${row.id}]`,
row.timestamp,
`Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`,
`Status: ${row.status}`,
`Endpoint: ${row.endpoint ?? '-'}`,
`Nachricht: ${row.message ?? '-'}`,
`Redeploy: ${row.redeployType ?? '-'}`
];
return parts.join(' | ');
});
return {
filename: `redeploy-logs-${timestamp}.txt`,
contentType: 'text/plain; charset=utf-8',
content: lines.join('\n')
};
}
+246 -4
View File
@@ -1,10 +1,18 @@
import { db } from '../db/index.js';
import {
normalizeAvatarColor,
DEFAULT_AVATAR_COLOR,
SUPERUSER_GROUP_NAME,
pickRandomAvatarColor
} from '../auth/superuser.js';
import { logEvent } from '../logging/eventLogs.js';
const selectGroupsWithMembers = db.prepare(`
SELECT
g.id,
g.name,
g.description,
g.avatar_color,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
@@ -21,6 +29,7 @@ const selectGroupWithMembersById = db.prepare(`
g.id,
g.name,
g.description,
g.avatar_color,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
@@ -32,6 +41,12 @@ const selectGroupWithMembersById = db.prepare(`
GROUP BY g.id
`);
const selectGroupByIdBasic = db.prepare(`
SELECT id, name, description, avatar_color
FROM user_groups
WHERE id = ?
`);
const selectGroupIdByName = db.prepare(`
SELECT id
FROM user_groups
@@ -40,8 +55,28 @@ const selectGroupIdByName = db.prepare(`
`);
const insertGroupStatement = db.prepare(`
INSERT INTO user_groups (name, description, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateGroupStatement = db.prepare(`
UPDATE user_groups
SET name = ?,
description = ?,
avatar_color = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const countGroupMembersStatement = db.prepare(`
SELECT COUNT(*) AS member_count
FROM user_group_memberships
WHERE group_id = ?
`);
const deleteGroupStatement = db.prepare(`
DELETE FROM user_groups
WHERE id = ?
`);
const parseMembers = (rawPairs) => {
@@ -66,6 +101,7 @@ const sanitizeGroup = (row) => ({
id: row.id,
name: row.name,
description: row.description || '',
avatarColor: row.avatar_color || null,
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
memberCount: Number(row.member_count) || 0,
@@ -77,6 +113,15 @@ export function listGroups() {
return rows.map(sanitizeGroup);
}
export function getGroupById(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return null;
}
const row = selectGroupWithMembersById.get(numericId);
return row ? sanitizeGroup(row) : null;
}
export function createGroup({ name, description }) {
const normalizedName = typeof name === 'string' ? name.trim() : '';
if (!normalizedName) {
@@ -93,8 +138,205 @@ export function createGroup({ name, description }) {
}
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null);
const avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
const groupId = Number(insertResult.lastInsertRowid);
const row = selectGroupWithMembersById.get(groupId);
return row ? sanitizeGroup(row) : null;
const group = row ? sanitizeGroup(row) : null;
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-angelegt',
action: 'anlegen',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(groupId),
entityName: group?.name ?? normalizedName,
message: `Benutzergruppe "${normalizedName}" angelegt`,
metadata: {
description: group?.description ?? normalizedDescription ?? null,
avatarColor
}
});
return group;
}
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const existingRow = selectGroupByIdBasic.get(numericId);
if (!existingRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
if (!normalizedName) {
const error = new Error('GROUP_NAME_REQUIRED');
error.code = 'GROUP_NAME_REQUIRED';
throw error;
}
const nameRow = selectGroupIdByName.get(normalizedName);
if (nameRow && Number(nameRow.id) !== numericId) {
const error = new Error('GROUP_NAME_TAKEN');
error.code = 'GROUP_NAME_TAKEN';
throw error;
}
let normalizedDescription;
if (description === undefined) {
normalizedDescription = existingRow.description ?? null;
} else if (description === null) {
normalizedDescription = null;
} else {
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
normalizedDescription = trimmedDescription ? trimmedDescription : null;
}
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
if (isSuperuserGroup) {
if (name !== undefined) {
const incomingName = typeof name === 'string' ? name.trim() : '';
if (incomingName && incomingName !== existingRow.name) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
}
if (description !== undefined) {
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
? ''
: String(existingRow.description).trim();
const incomingDescriptionComparable = description === null
? ''
: typeof description === 'string'
? description.trim()
: '';
if (incomingDescriptionComparable !== existingDescriptionComparable) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
}
// Enforce original values for protected fields
normalizedDescription = existingRow.description ?? null;
}
let colorToPersist = existingRow.avatar_color || null;
if (avatarColor !== undefined) {
const candidate = String(avatarColor || '').trim();
if (!candidate) {
colorToPersist = DEFAULT_AVATAR_COLOR;
} else {
const normalized = normalizeAvatarColor(candidate);
if (!normalized) {
const error = new Error('INVALID_AVATAR_COLOR');
error.code = 'INVALID_AVATAR_COLOR';
throw error;
}
colorToPersist = normalized;
}
}
updateGroupStatement.run(
isSuperuserGroup ? existingRow.name : normalizedName,
normalizedDescription,
colorToPersist,
numericId
);
const updatedGroup = getGroupById(numericId);
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-aktualisiert',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(numericId),
entityName: updatedGroup?.name ?? normalizedName,
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
metadata: {
previous: {
name: existingRow.name,
description: existingRow.description ?? null,
avatarColor: existingRow.avatar_color ?? null
},
current: updatedGroup
? {
name: updatedGroup.name,
description: updatedGroup.description ?? null,
avatarColor: updatedGroup.avatarColor ?? null,
memberCount: updatedGroup.memberCount ?? 0
}
: {
name: normalizedName,
description: normalizedDescription ?? null,
avatarColor: colorToPersist
}
}
});
return updatedGroup;
}
export function deleteGroup(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const existingRow = selectGroupByIdBasic.get(numericId);
if (!existingRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
if (isSuperuserGroup) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
if (Number(memberCount) > 0) {
const error = new Error('GROUP_HAS_MEMBERS');
error.code = 'GROUP_HAS_MEMBERS';
error.memberCount = Number(memberCount);
throw error;
}
deleteGroupStatement.run(numericId);
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-gelöscht',
action: 'löschen',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(numericId),
entityName: existingRow.name ?? `ID ${numericId}`,
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
metadata: {
description: existingRow.description ?? null,
avatarColor: existingRow.avatar_color ?? null,
memberCountBeforeDelete: Number(memberCount)
}
});
}
+521 -129
View File
@@ -23,12 +23,12 @@ import {
verifyPassword
} from './auth/superuser.js';
import {
logRedeployEvent,
buildLogFilter,
deleteLogById,
deleteLogsByFilters,
exportLogsByFilters
} from './db/redeployLogs.js';
logEvent,
buildEventLogFilter,
deleteEventLogById,
deleteEventLogsByFilters,
exportEventLogsByFilters
} from './logging/eventLogs.js';
import { getSetting, setSetting, deleteSetting } from './db/settings.js';
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
import {
@@ -45,8 +45,8 @@ import {
removeServer,
setServerApiKey
} from './setup/index.js';
import { listUsers, getUserById, updateUserGroups, createUser } from './users/index.js';
import { listGroups, createGroup } from './groups/index.js';
import { listUsers, getUserById, updateUserGroups, createUser, updateUserDetails, deleteUser, updateUserActiveStatus } from './users/index.js';
import { listGroups, createGroup, getGroupById, updateGroupDetails, deleteGroup } from './groups/index.js';
dotenv.config();
@@ -301,6 +301,53 @@ const REDEPLOY_TYPES = {
MAINTENANCE: 'Wartung'
};
const logStackEvent = ({
stackId,
stackName,
status,
message,
redeployType = null,
endpointId = null,
metadata = {}
}) => {
const metadataPayload = {
...metadata
};
if (redeployType) {
metadataPayload.redeployType = redeployType;
}
if (endpointId !== undefined && endpointId !== null) {
metadataPayload.endpointId = String(endpointId);
}
const hasMetadata = Object.keys(metadataPayload).length > 0;
logEvent({
category: 'stack',
eventType: redeployType ?? null,
action: 'redeploy',
status: status ?? null,
entityType: 'stack',
entityId: stackId !== undefined && stackId !== null ? String(stackId) : null,
entityName: stackName ?? null,
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
message: message ?? null,
metadata: hasMetadata ? metadataPayload : null
});
};
const parseJsonColumn = (value) => {
if (value === undefined || value === null || value === '') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
};
const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null;
const PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script';
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
@@ -942,22 +989,40 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
});
maintenanceActivated = true;
logRedeployEvent({
stackId: 'portainer',
stackName: 'Portainer',
status: 'started',
logEvent({
category: 'wartung',
eventType: 'portainer-update',
action: 'aktualisieren',
status: 'gestartet',
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
metadata: {
targetVersion: targetVersion ?? null,
scriptSource
},
source: 'system'
});
logRedeployEvent({
stackId: 'maintenance',
stackName: 'StackPulse Wartung',
status: 'started',
logEvent({
category: 'wartung',
eventType: 'Wartungsmodus',
action: 'aktivieren',
status: 'gestartet',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Wartungsmodus aktiviert (Portainer Update)',
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
metadata: {
reason: 'portainer-update',
scriptSource
},
source: 'system'
});
updatePortainerState({
@@ -981,13 +1046,21 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
const statusAfter = await fetchPortainerStatusSummary().catch(() => null);
const finalVersion = statusAfter?.currentVersion ?? null;
logRedeployEvent({
stackId: 'portainer',
stackName: 'Portainer',
status: 'success',
logEvent({
category: 'wartung',
eventType: 'portainer-update',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
metadata: {
resultVersion: finalVersion ?? null
},
source: 'system'
});
updatePortainerState({
@@ -1005,24 +1078,37 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
if (maintenanceActivated || maintenanceState?.active) {
deactivateMaintenanceMode({ message: 'Portainer Update abgeschlossen' });
maintenanceActivated = false;
logRedeployEvent({
stackId: 'maintenance',
stackName: 'StackPulse Wartung',
status: 'success',
logEvent({
category: 'wartung',
eventType: 'Wartungsmodus',
action: 'deaktivieren',
status: 'erfolgreich',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Wartungsmodus deaktiviert',
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
metadata: {
reason: 'portainer-update'
},
source: 'system'
});
}
} catch (err) {
const message = err?.message || 'Portainer Update fehlgeschlagen';
logRedeployEvent({
stackId: 'portainer',
stackName: 'Portainer',
status: 'error',
logEvent({
category: 'wartung',
eventType: 'portainer-update',
action: 'aktualisieren',
status: 'fehler',
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
message,
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
source: 'system'
});
updatePortainerState({
@@ -1038,13 +1124,21 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
if (maintenanceActivated || isMaintenanceModeActive()) {
deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' });
logRedeployEvent({
stackId: 'maintenance',
stackName: 'StackPulse Wartung',
status: 'error',
logEvent({
category: 'wartung',
eventType: 'Wartungsmodus',
action: 'deaktivieren',
status: 'fehler',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Wartungsmodus deaktiviert (Fehler)',
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
metadata: {
reason: 'portainer-update'
},
source: 'system'
});
}
} finally {
@@ -1191,12 +1285,12 @@ const redeployStackById = async (stackId, redeployType) => {
phase: REDEPLOY_PHASES.STARTED
});
logRedeployEvent({
logStackEvent({
stackId: targetId,
stackName: targetName,
status: 'started',
message: messages.started,
endpoint: stack.EndpointId,
endpointId: stack.EndpointId,
redeployType
});
@@ -1271,12 +1365,12 @@ const redeployStackById = async (stackId, redeployType) => {
phase: REDEPLOY_PHASES.SUCCESS
});
logRedeployEvent({
logStackEvent({
stackId: targetId,
stackName: targetName,
status: 'success',
message: messages.success,
endpoint: stack.EndpointId,
endpointId: stack.EndpointId,
redeployType
});
@@ -1293,12 +1387,12 @@ const redeployStackById = async (stackId, redeployType) => {
message: errorMessage
});
logRedeployEvent({
logStackEvent({
stackId: fallbackId,
stackName: fallbackName,
status: 'error',
message: errorMessage,
endpoint: stack?.EndpointId || endpointId,
endpointId: stack?.EndpointId || endpointId,
redeployType
});
@@ -1621,10 +1715,14 @@ app.post('/api/auth/login', (req, res) => {
}
const user = findUserByIdentifier(identifier);
if (!user || !user.is_active) {
if (!user) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
}
if (!user.is_active) {
return res.status(403).json({ error: 'USER_INACTIVE' });
}
const passwordValid = verifyPassword(password, user.password_hash, user.password_salt);
if (!passwordValid) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
@@ -1731,6 +1829,63 @@ app.get('/api/users/:userId', (req, res) => {
}
});
app.put('/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' });
}
const { username, email, password, avatarColor, groupId, groupIds } = req.body ?? {};
try {
const updatedUser = updateUserDetails(numericId, {
username,
email,
password,
avatarColor,
groupId,
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 === 'USERNAME_REQUIRED') {
return res.status(400).json({ error: 'USERNAME_REQUIRED' });
}
if (error.code === 'INVALID_EMAIL') {
return res.status(400).json({ error: 'INVALID_EMAIL' });
}
if (error.code === 'USERNAME_TAKEN') {
return res.status(409).json({ error: 'USERNAME_TAKEN' });
}
if (error.code === 'EMAIL_TAKEN') {
return res.status(409).json({ error: 'EMAIL_TAKEN' });
}
if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') {
return res.status(400).json({ error: error.code });
}
if (error.code === 'INVALID_AVATAR_COLOR') {
return res.status(400).json({ error: 'INVALID_AVATAR_COLOR' });
}
if (error.code === 'GROUP_NOT_FOUND') {
return res.status(400).json({ error: 'GROUP_NOT_FOUND', details: error.missingGroupIds || [] });
}
if (error.code === 'GROUP_SUPERUSER_PROTECTED') {
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
}
console.error(`⚠️ [Users] Aktualisierung der Benutzerdaten fehlgeschlagen (${userId}):`, error);
res.status(500).json({ error: 'USER_UPDATE_FAILED' });
}
});
app.put('/api/users/:userId/groups', (req, res) => {
const { userId } = req.params;
const numericId = Number(userId);
@@ -1758,6 +1913,61 @@ app.put('/api/users/:userId/groups', (req, res) => {
}
});
app.delete('/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 {
deleteUser(numericId);
res.json({ success: true });
} 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 === 'USER_SUPERUSER_PROTECTED') {
return res.status(403).json({ error: 'USER_SUPERUSER_PROTECTED' });
}
console.error(`⚠️ [Users] Löschen fehlgeschlagen (${userId}):`, error);
res.status(500).json({ error: 'USER_DELETE_FAILED' });
}
});
app.put('/api/users/:userId/active', (req, res) => {
const { userId } = req.params;
const numericId = Number(userId);
const { isActive } = req.body ?? {};
if (!Number.isFinite(numericId) || numericId <= 0) {
return res.status(400).json({ error: 'INVALID_USER_ID' });
}
try {
const updatedUser = updateUserActiveStatus(numericId, Boolean(isActive));
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 === 'USER_SUPERUSER_PROTECTED') {
return res.status(403).json({ error: 'USER_SUPERUSER_PROTECTED' });
}
console.error(`⚠️ [Users] Aktualisierung des Aktivstatus fehlgeschlagen (${userId}):`, error);
res.status(500).json({ error: 'USER_STATUS_UPDATE_FAILED' });
}
});
app.get('/api/groups', (req, res) => {
try {
const groups = listGroups();
@@ -1768,6 +1978,26 @@ app.get('/api/groups', (req, res) => {
}
});
app.get('/api/groups/:groupId', (req, res) => {
const { groupId } = req.params;
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
}
try {
const group = getGroupById(numericId);
if (!group) {
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
}
res.json({ item: group });
} catch (error) {
console.error(`⚠️ [Groups] Abruf der Gruppendetails fehlgeschlagen (${groupId}):`, error);
res.status(500).json({ error: 'GROUP_FETCH_FAILED' });
}
});
app.post('/api/groups', (req, res) => {
const { name, description } = req.body ?? {};
try {
@@ -1785,6 +2015,74 @@ app.post('/api/groups', (req, res) => {
}
});
app.put('/api/groups/:groupId', (req, res) => {
const { groupId } = req.params;
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
}
const { name, description, avatarColor } = req.body ?? {};
try {
const updatedGroup = updateGroupDetails(numericId, { name, description, avatarColor });
res.json({ item: updatedGroup });
} catch (error) {
if (error.code === 'INVALID_GROUP_ID') {
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
}
if (error.code === 'GROUP_NOT_FOUND') {
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
}
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' });
}
if (error.code === 'INVALID_AVATAR_COLOR') {
return res.status(400).json({ error: 'INVALID_AVATAR_COLOR' });
}
if (error.code === 'GROUP_SUPERUSER_PROTECTED') {
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
}
console.error(`⚠️ [Groups] Aktualisierung der Gruppendaten fehlgeschlagen (${groupId}):`, error);
res.status(500).json({ error: 'GROUP_UPDATE_FAILED' });
}
});
app.delete('/api/groups/:groupId', (req, res) => {
const { groupId } = req.params;
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
}
try {
deleteGroup(numericId);
res.json({ success: true });
} catch (error) {
if (error.code === 'INVALID_GROUP_ID') {
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
}
if (error.code === 'GROUP_NOT_FOUND') {
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
}
if (error.code === 'GROUP_HAS_MEMBERS') {
return res.status(409).json({ error: 'GROUP_HAS_MEMBERS', memberCount: error.memberCount || 0 });
}
if (error.code === 'GROUP_SUPERUSER_PROTECTED') {
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
}
console.error(`⚠️ [Groups] Löschen der Gruppe fehlgeschlagen (${groupId}):`, error);
res.status(500).json({ error: 'GROUP_DELETE_FAILED' });
}
});
// Superuser Setup
app.get('/api/auth/superuser/status', (req, res) => {
const exists = hasSuperuser();
@@ -2198,13 +2496,16 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
return res.status(400).json({ error: 'Keine passenden Duplikate gefunden' });
}
logRedeployEvent({
logStackEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'started',
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
duplicateIds: duplicateIds
}
});
const results = [];
@@ -2238,13 +2539,16 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
if (errors.length) {
const failedIds = errors.map((entry) => entry.id).join(', ');
logRedeployEvent({
logStackEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'error',
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
duplicateIds: duplicateIds
}
});
return res.status(500).json({
@@ -2258,13 +2562,16 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
});
}
logRedeployEvent({
logStackEvent({
stackId: target.canonical.Id,
stackName: target.canonical.Name,
status: 'success',
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
endpoint: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE
endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
removedDuplicates: results.filter((entry) => entry.status === 'deleted').map((entry) => entry.id)
}
});
res.json({
@@ -2284,7 +2591,7 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
}
});
// Redeploy-Logs abrufen
// Event-Logs abrufen
app.get('/api/logs', (req, res) => {
const perPageParam = req.query.perPage ?? req.query.limit;
const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
@@ -2293,25 +2600,36 @@ app.get('/api/logs', (req, res) => {
const limit = perPage === 'all' ? undefined : perPage;
const offset = perPage === 'all' ? 0 : (page - 1) * perPage;
const { whereClause, params } = buildLogFilter(req.query);
const { whereClause, params } = buildEventLogFilter(req.query);
const baseQuery = `
SELECT
id,
timestamp,
stack_id AS stackId,
stack_name AS stackName,
category,
event_type AS eventType,
action,
status,
severity,
entity_type AS entityType,
entity_id AS entityId,
entity_name AS entityName,
actor_type AS actorType,
actor_id AS actorId,
actor_name AS actorName,
source,
context_type AS contextType,
context_id AS contextId,
context_label AS contextLabel,
message,
endpoint,
redeploy_type AS redeployType
FROM redeploy_logs
metadata
FROM event_logs
${whereClause}
ORDER BY datetime(timestamp) DESC
`;
const countQuery = `
SELECT COUNT(*) as total
FROM redeploy_logs
FROM event_logs
${whereClause}
`;
@@ -2326,7 +2644,25 @@ app.get('/api/logs', (req, res) => {
try {
const stmt = db.prepare(query);
const logs = stmt.all(params);
const rawLogs = stmt.all(params);
const logs = rawLogs.map((row) => {
const metadata = parseJsonColumn(row.metadata);
const legacyStack = row.entityType === 'stack' ? (row.entityId ?? null) : null;
const legacyStackName = row.entityType === 'stack' ? (row.entityName ?? row.entityId ?? null) : null;
const legacyEndpoint = row.contextType === 'endpoint' ? (row.contextId ?? null) : null;
return {
...row,
entityId: row.entityId ?? null,
actorId: row.actorId ?? null,
contextId: row.contextId ?? null,
metadata,
stackId: legacyStack,
stackName: legacyStackName,
redeployType: row.eventType ?? null,
endpoint: legacyEndpoint
};
});
const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
res.json({
@@ -2336,11 +2672,8 @@ app.get('/api/logs', (req, res) => {
perPage: perPage === 'all' ? 'all' : limit
});
} catch (err) {
console.error('❌ Fehler beim Abrufen der Redeploy-Logs:', err.message);
if (err.message.includes('no such table')) {
return res.status(500).json({ error: 'redeploy_logs table nicht gefunden. Bitte Migration ausführen.' });
}
res.status(500).json({ error: 'Fehler beim Abrufen der Redeploy-Logs' });
console.error('❌ Fehler beim Abrufen der Event-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Abrufen der Logs' });
}
});
@@ -2351,24 +2684,24 @@ app.delete('/api/logs/:id', (req, res) => {
}
try {
const changes = deleteLogById(id);
const changes = deleteEventLogById(id);
if (!changes) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
res.json({ success: true, deleted: changes });
} catch (err) {
console.error('❌ Fehler beim Löschen des Redeploy-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Löschen des Redeploy-Logs' });
console.error('❌ Fehler beim Löschen des Event-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Löschen des Logs' });
}
});
app.delete('/api/logs', (req, res) => {
try {
const deleted = deleteLogsByFilters(req.query);
const deleted = deleteEventLogsByFilters(req.query);
res.json({ success: true, deleted });
} catch (err) {
console.error('❌ Fehler beim Löschen der Redeploy-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Löschen der Redeploy-Logs' });
console.error('❌ Fehler beim Löschen der Event-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Löschen der Logs' });
}
});
@@ -2379,13 +2712,13 @@ app.get('/api/logs/export', (req, res) => {
}
try {
const payload = exportLogsByFilters(req.query, format);
const payload = exportEventLogsByFilters(req.query, format);
res.setHeader('Content-Type', payload.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`);
res.send(payload.content);
} catch (err) {
console.error('❌ Fehler beim Export der Redeploy-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Export der Redeploy-Logs' });
console.error('❌ Fehler beim Export der Event-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Export der Logs' });
}
});
@@ -2409,8 +2742,10 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
let endpointId;
try {
const endpointId = requireActiveEndpointId();
endpointId = requireActiveEndpointId();
const stacksRes = await axiosInstance.get('/api/stacks');
const filteredStacks = stacksRes.data.filter(stack => String(stack.EndpointId) === String(endpointId));
@@ -2427,13 +2762,21 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
const stackSummaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks';
logRedeployEvent({
stackId: '---',
stackName: '---',
status: 'started',
message: `Redeploy ALL gestartet für: ${stackSummary}`,
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.ALL
logEvent({
category: 'stack',
eventType: REDEPLOY_TYPES.ALL,
action: 'redeploy-alle',
status: 'gestartet',
entityType: 'bulk-operation',
entityId: 'redeploy-alle',
entityName: 'Redeploy ALLE',
contextType: 'endpoint',
contextId: String(endpointId),
message: `Redeploy ALLE gestartet für: ${stackSummary}`,
metadata: {
stacks: stackSummaryList
},
source: 'system'
});
if (!eligibleStacks.length) {
@@ -2458,27 +2801,40 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
}
}
logRedeployEvent({
stackId: '---',
stackName: '---',
status: 'success',
message: 'Redeploy ALL abgeschlossen',
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.ALL
logEvent({
category: 'stack',
eventType: REDEPLOY_TYPES.ALL,
action: 'redeploy-alle',
status: 'erfolgreich',
entityType: 'bulk-operation',
entityId: 'redeploy-alle',
entityName: 'Redeploy ALLE',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Redeploy ALLE abgeschlossen',
metadata: {
processedStacks: stackSummaryList
},
source: 'system'
});
res.json({ success: true, message: 'Redeploy ALL abgeschlossen' });
} catch (err) {
const message = err.response?.data?.message || err.message;
logRedeployEvent({
stackId: '---',
stackName: '---',
logEvent({
category: 'stack',
eventType: REDEPLOY_TYPES.ALL,
action: 'redeploy-alle',
status: 'error',
entityType: 'bulk-operation',
entityId: 'redeploy-alle',
entityName: 'Redeploy ALLE',
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
message,
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.ALL
source: 'system'
});
console.error('❌ Fehler bei Redeploy ALL:', message);
console.error('❌ Fehler bei Redeploy ALLE:', message);
res.status(500).json({ error: message });
}
});
@@ -2493,8 +2849,10 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' });
}
let endpointId;
try {
const endpointId = requireActiveEndpointId();
endpointId = requireActiveEndpointId();
const normalizedIds = stackIds.map((id) => String(id));
const { filteredStacks } = await loadStackCollections();
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack]));
@@ -2520,23 +2878,40 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
const summaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
const summaryText = summaryList.length ? summaryList.join(', ') : 'keine Stacks';
logRedeployEvent({
stackId: stackIds.join(','),
stackName: `Auswahl (${stackIds.length})`,
status: 'started',
logEvent({
category: 'stack',
eventType: REDEPLOY_TYPES.SELECTION,
action: 'redeploy-auswahl',
status: 'gestartet',
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${stackIds.length})`,
contextType: 'endpoint',
contextId: String(endpointId),
message: `Redeploy Auswahl gestartet für: ${summaryText}`,
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.SELECTION
metadata: {
requestedStackIds: normalizedIds
},
source: 'system'
});
if (!eligibleStacks.length) {
logRedeployEvent({
stackId: stackIds.join(','),
stackName: `Auswahl (${stackIds.length})`,
status: 'success',
logEvent({
category: 'stack',
eventType: REDEPLOY_TYPES.SELECTION,
action: 'redeploy-auswahl',
status: 'erfolgreich',
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${stackIds.length})`,
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.SELECTION
metadata: {
requestedStackIds: normalizedIds,
skipped: true
},
source: 'system'
});
return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' });
}
@@ -2558,25 +2933,42 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
}
}
logRedeployEvent({
stackId: stackIds.join(','),
stackName: `Auswahl (${stackIds.length})`,
status: 'success',
logEvent({
category: 'stack',
eventType: REDEPLOY_TYPES.SELECTION,
action: 'redeploy-auswahl',
status: 'erfolgreich',
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${stackIds.length})`,
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Redeploy Auswahl abgeschlossen',
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.SELECTION
metadata: {
processedStackIds: eligibleStacks.map((stack) => String(stack.Id))
},
source: 'system'
});
res.json({ success: true, message: 'Redeploy Auswahl abgeschlossen' });
} catch (err) {
const message = err.response?.data?.message || err.message;
logRedeployEvent({
stackId: Array.isArray(stackIds) ? stackIds.join(',') : String(stackIds ?? ''),
stackName: `Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
status: 'error',
const normalized = Array.isArray(stackIds) ? stackIds.map((id) => String(id)) : [];
logEvent({
category: 'stack',
eventType: REDEPLOY_TYPES.SELECTION,
action: 'redeploy-auswahl',
status: 'fehler',
entityType: 'bulk-operation',
entityId: 'redeploy-auswahl',
entityName: `Redeploy Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
message,
endpoint: endpointId,
redeployType: REDEPLOY_TYPES.SELECTION
metadata: {
requestedStackIds: normalized
},
source: 'system'
});
console.error('❌ Fehler bei Redeploy Auswahl:', message);
res.status(500).json({ error: message });
+331
View File
@@ -0,0 +1,331 @@
import { db } from '../db/index.js';
const valueToArray = (value) => {
if (value === undefined || value === null) return [];
const base = Array.isArray(value) ? value : [value];
return base
.flatMap((entry) => String(entry).split(','))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
};
const singleValue = (value) => {
if (value === undefined || value === null) return undefined;
return Array.isArray(value) ? value[0] : value;
};
const serializeJson = (value) => {
if (value === undefined || value === null || value === '') return null;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (err) {
console.error('⚠️ Konnte JSON nicht serialisieren:', err.message);
return JSON.stringify({ serializationError: true });
}
};
const appendInFilter = (filters, params, values, column, prefix) => {
if (!values.length) return;
const placeholders = values.map((_, idx) => {
const key = `${prefix}${idx}`;
params[key] = values[idx];
return `@${key}`;
});
filters.push(`${column} IN (${placeholders.join(', ')})`);
};
export function buildEventLogFilter(queryParams = {}) {
const filters = [];
const params = {};
const ids = valueToArray(queryParams.ids ?? queryParams.id)
.map((entry) => Number(entry))
.filter((value) => Number.isFinite(value));
if (ids.length) {
appendInFilter(filters, params, ids, 'id', 'id');
}
const categories = valueToArray(queryParams.categories ?? queryParams.category);
appendInFilter(filters, params, categories, 'category', 'category');
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
appendInFilter(filters, params, statuses, 'status', 'status');
const actions = valueToArray(queryParams.actions ?? queryParams.action);
appendInFilter(filters, params, actions, 'action', 'action');
const eventTypes = [
...valueToArray(queryParams.eventTypes ?? queryParams.eventType),
...valueToArray(queryParams.redeployTypes ?? queryParams.redeployType)
];
if (eventTypes.length) {
const unique = Array.from(new Set(eventTypes));
appendInFilter(filters, params, unique, 'event_type', 'eventType');
}
const severities = valueToArray(queryParams.severities ?? queryParams.severity);
appendInFilter(filters, params, severities, 'severity', 'severity');
const entityTypes = valueToArray(queryParams.entityTypes ?? queryParams.entityType);
appendInFilter(filters, params, entityTypes, 'entity_type', 'entityType');
const entityIds = valueToArray(queryParams.entityIds ?? queryParams.entityId);
appendInFilter(filters, params, entityIds, 'entity_id', 'entityId');
const actorTypes = valueToArray(queryParams.actorTypes ?? queryParams.actorType);
appendInFilter(filters, params, actorTypes, 'actor_type', 'actorType');
const actorIds = valueToArray(queryParams.actorIds ?? queryParams.actorId);
appendInFilter(filters, params, actorIds, 'actor_id', 'actorId');
const sources = valueToArray(queryParams.sources ?? queryParams.source);
appendInFilter(filters, params, sources, 'source', 'source');
const contextTypes = valueToArray(queryParams.contextTypes ?? queryParams.contextType);
appendInFilter(filters, params, contextTypes, 'context_type', 'contextType');
const contextIds = valueToArray(queryParams.contextIds ?? queryParams.contextId);
appendInFilter(filters, params, contextIds, 'context_id', 'contextId');
const legacyStackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
if (legacyStackIds.length) {
const placeholders = legacyStackIds.map((_, idx) => {
const key = `legacyStackId${idx}`;
params[key] = legacyStackIds[idx];
return `@${key}`;
});
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
}
const legacyEndpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
if (legacyEndpoints.length) {
const placeholders = legacyEndpoints.map((_, idx) => {
const key = `legacyEndpoint${idx}`;
params[key] = legacyEndpoints[idx];
return `@${key}`;
});
filters.push(`(context_type = 'endpoint' AND context_id IN (${placeholders.join(', ')}))`);
}
const messageQuery = singleValue(queryParams.message ?? queryParams.text);
if (messageQuery && String(messageQuery).trim()) {
filters.push('message LIKE @message');
params.message = `%${String(messageQuery).trim()}%`;
}
const search = singleValue(queryParams.search ?? queryParams.q);
if (search && String(search).trim()) {
filters.push('(message LIKE @search OR entity_name LIKE @search OR metadata LIKE @search)');
params.search = `%${String(search).trim()}%`;
}
const from = singleValue(queryParams.from ?? queryParams.start);
if (from) {
filters.push('timestamp >= @from');
params.from = from;
}
const to = singleValue(queryParams.to ?? queryParams.end);
if (to) {
filters.push('timestamp <= @to');
params.to = to;
}
return {
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
params
};
}
const insertEventLogStmt = db.prepare(`
INSERT INTO event_logs (
timestamp,
category,
event_type,
action,
status,
severity,
entity_type,
entity_id,
entity_name,
actor_type,
actor_id,
actor_name,
source,
context_type,
context_id,
context_label,
message,
metadata
)
VALUES (
COALESCE(@timestamp, CURRENT_TIMESTAMP),
@category,
@eventType,
@action,
@status,
@severity,
@entityType,
@entityId,
@entityName,
@actorType,
@actorId,
@actorName,
@source,
@contextType,
@contextId,
@contextLabel,
@message,
@metadata
)
`);
export function logEvent(event = {}) {
if (!event.category) {
console.error('⚠️ logEvent wurde ohne Kategorie aufgerufen');
return;
}
try {
insertEventLogStmt.run({
timestamp: event.timestamp ?? null,
category: event.category,
eventType: event.eventType ?? null,
action: event.action ?? null,
status: event.status ?? null,
severity: event.severity ?? null,
entityType: event.entityType ?? null,
entityId: event.entityId !== undefined && event.entityId !== null ? String(event.entityId) : null,
entityName: event.entityName ?? null,
actorType: event.actorType ?? null,
actorId: event.actorId !== undefined && event.actorId !== null ? String(event.actorId) : null,
actorName: event.actorName ?? null,
source: event.source ?? null,
contextType: event.contextType ?? null,
contextId: event.contextId !== undefined && event.contextId !== null ? String(event.contextId) : null,
contextLabel: event.contextLabel ?? null,
message: event.message ?? null,
metadata: serializeJson(event.metadata)
});
} catch (err) {
console.error('❌ Fehler beim Speichern des Event-Logs:', err.message);
}
}
export function deleteEventLogById(id) {
const stmt = db.prepare('DELETE FROM event_logs WHERE id = ?');
const info = stmt.run(id);
return info.changes || 0;
}
export function deleteEventLogsByFilters(queryParams = {}) {
const { whereClause, params } = buildEventLogFilter(queryParams);
const stmt = db.prepare(`DELETE FROM event_logs ${whereClause}`);
const info = stmt.run(params);
return info.changes || 0;
}
export function exportEventLogsByFilters(queryParams = {}, format = 'txt') {
const { whereClause, params } = buildEventLogFilter(queryParams);
const rows = db.prepare(`
SELECT
id,
timestamp,
category,
event_type AS eventType,
action,
status,
severity,
entity_type AS entityType,
entity_id AS entityId,
entity_name AS entityName,
actor_type AS actorType,
actor_id AS actorId,
actor_name AS actorName,
source,
context_type AS contextType,
context_id AS contextId,
context_label AS contextLabel,
message,
metadata
FROM event_logs
${whereClause}
ORDER BY datetime(timestamp) DESC
`).all(params);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
if (format === 'sql') {
const columns = [
'id',
'timestamp',
'category',
'event_type',
'action',
'status',
'severity',
'entity_type',
'entity_id',
'entity_name',
'actor_type',
'actor_id',
'actor_name',
'source',
'context_type',
'context_id',
'context_label',
'message',
'metadata'
];
const statements = rows.map((row) => {
const values = columns.map((column) => {
const key = {
event_type: 'eventType',
entity_type: 'entityType',
entity_id: 'entityId',
entity_name: 'entityName',
actor_type: 'actorType',
actor_id: 'actorId',
actor_name: 'actorName',
context_type: 'contextType',
context_id: 'contextId',
context_label: 'contextLabel'
}[column] ?? column;
const value = row[key];
if (value === null || value === undefined) return 'NULL';
return `'${String(value).replace(/'/g, "''")}'`;
});
return `INSERT INTO event_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
});
return {
filename: `event-logs-${timestamp}.sql`,
contentType: 'application/sql; charset=utf-8',
content: statements.join('\n')
};
}
const lines = rows.map((row) => {
const parts = [
`[${row.id}]`,
row.timestamp,
`Kategorie: ${row.category}`,
row.eventType ? `Typ: ${row.eventType}` : null,
row.action ? `Aktion: ${row.action}` : null,
row.status ? `Status: ${row.status}` : null,
row.entityName ? `Entität: ${row.entityName}${row.entityId ? ` (#${row.entityId})` : ''}` : row.entityId ? `Entität-ID: ${row.entityId}` : null,
row.contextType ? `Kontext: ${row.contextType}${row.contextId ? ` (#${row.contextId}${row.contextLabel ? ` ${row.contextLabel}` : ''})` : ''}` : null,
row.message ? `Nachricht: ${row.message}` : null,
row.metadata ? `Metadaten: ${row.metadata}` : null
].filter(Boolean);
return parts.join(' | ');
});
return {
filename: `event-logs-${timestamp}.txt`,
contentType: 'text/plain; charset=utf-8',
content: lines.join('\n')
};
}
+27
View File
@@ -1,4 +1,5 @@
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
import { logEvent } from '../logging/eventLogs.js';
const MAINTENANCE_KEY = 'maintenance_mode';
@@ -44,6 +45,19 @@ export function isMaintenanceModeActive() {
}
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
logEvent({
category: 'wartung',
eventType: 'Wartungsmodus',
action: 'aktivieren',
status: 'gestartet',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'System',
contextId: 'System',
message: 'Wartungsmodus aktiviert',
source: 'system'
});
const now = new Date().toISOString();
return persistState({
active: true,
@@ -54,6 +68,19 @@ export function activateMaintenanceMode({ message = null, extra = null } = {}) {
}
export function deactivateMaintenanceMode({ message = null } = {}) {
logEvent({
category: 'wartung',
eventType: 'wartungsmodus',
action: 'deaktivieren',
status: 'erfolgreich',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'System',
contextId: 'System',
message: 'Wartungsmodus deaktiviert',
source: 'system'
});
return persistState({
active: false,
message,
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 type="module" crossorigin src="/assets/index-665b980d.js"></script>
<link rel="stylesheet" href="/assets/index-5f261614.css">
<script type="module" crossorigin src="/assets/index-8d2b4f50.js"></script>
<link rel="stylesheet" href="/assets/index-11780ccd.css">
</head>
<body>
<div id="root"></div>
+365 -5
View File
@@ -1,9 +1,11 @@
import { db } from '../db/index.js';
import { logEvent } from '../logging/eventLogs.js';
import {
hashPassword,
normalizeAvatarColor,
pickRandomAvatarColor,
DEFAULT_AVATAR_COLOR
DEFAULT_AVATAR_COLOR,
SUPERUSER_GROUP_NAME
} from '../auth/superuser.js';
const selectUsersWithGroups = db.prepare(`
@@ -42,6 +44,12 @@ const selectUserWithGroupsById = db.prepare(`
GROUP BY u.id
`);
const selectUserCredentialsById = db.prepare(`
SELECT id, username, email, password_hash, password_salt, avatar_color
FROM users
WHERE id = ?
`);
const selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?');
@@ -56,12 +64,39 @@ const insertMembershipForUser = db.prepare(`
`);
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
const isUserInGroupStatement = db.prepare(`
SELECT 1 AS has_membership
FROM user_group_memberships m
INNER JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = ? AND lower(g.name) = lower(?)
LIMIT 1
`);
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 updateUserCoreStatement = db.prepare(`
UPDATE users
SET username = ?,
email = ?,
password_hash = ?,
password_salt = ?,
avatar_color = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const updateUserActiveStatement = db.prepare(`
UPDATE users
SET is_active = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const parseGroupPairs = (rawPairs) => {
if (!rawPairs) {
return [];
@@ -119,6 +154,18 @@ const insertUserWithGroups = db.transaction(({ username, email, passwordHash, pa
return userId;
});
const resolveActorFields = (actor) => {
if (!actor || actor.id === undefined || actor.id === null) {
return {};
}
const name = actor.username || actor.email || `User ${actor.id}`;
return {
actorType: 'user',
actorId: String(actor.id),
actorName: name
};
};
const normalizeEmail = (value) => {
if (!value) return null;
const trimmed = String(value).trim();
@@ -128,7 +175,8 @@ const normalizeEmail = (value) => {
return trimmed.toLowerCase();
};
export function createUser({ username, email, password, groupId, avatarColor }) {
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
const actor = options.actor || null;
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
@@ -150,6 +198,12 @@ export function createUser({ username, email, password, groupId, avatarColor })
throw error;
}
if ((groupRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
const normalizedEmail = normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL');
@@ -200,10 +254,30 @@ export function createUser({ username, email, password, groupId, avatarColor })
groupId: numericGroupId
});
return getUserById(userId);
const userRecord = getUserById(userId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-angelegt',
action: 'anlegen',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(userRecord?.id ?? userId),
entityName: userRecord?.username ?? normalizedUsername,
message: `Benutzer "${normalizedUsername}" angelegt`,
metadata: {
email: userRecord?.email ?? normalizedEmail ?? null,
primaryGroupId: numericGroupId,
primaryGroupName: groupRow.name
},
...resolveActorFields(actor)
});
return userRecord;
}
export function updateUserGroups(userId, groupIds) {
export function updateUserGroups(userId, groupIds, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
@@ -229,10 +303,16 @@ export function updateUserGroups(userId, groupIds) {
: [];
const missingGroupIds = [];
const groupDetails = [];
normalizedGroupIds.forEach((groupId) => {
const groupRow = selectGroupById.get(groupId);
if (!groupRow) {
missingGroupIds.push(groupId);
} else {
groupDetails.push({
id: groupId,
name: groupRow.name
});
}
});
@@ -244,5 +324,285 @@ export function updateUserGroups(userId, groupIds) {
}
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
return getUserById(numericUserId);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-gruppen-aktualisiert',
action: 'gruppe-aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: `Gruppenzuordnung für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktualisiert`,
metadata: {
previousGroups: (existingUser.groups || []).map((group) => ({ id: group.id, name: group.name })),
groups: updated?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? groupDetails
},
...resolveActorFields(actor)
});
return updated;
}
export function updateUserDetails(userId, { username, email, password, avatarColor, groupId, groupIds } = {}, options = {}) {
const actor = options.actor || null;
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 = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const previousUserRecord = getUserById(numericUserId);
const normalizedUsername = typeof username === 'string' ? username.trim() : existingUser.username;
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const usernameRow = selectUserByUsername.get(normalizedUsername);
if (usernameRow && Number(usernameRow.id) !== numericUserId) {
const error = new Error('USERNAME_TAKEN');
error.code = 'USERNAME_TAKEN';
throw error;
}
const normalizedEmail = email === undefined ? existingUser.email : normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL');
error.code = 'INVALID_EMAIL';
throw error;
}
if (normalizedEmail) {
const emailRow = selectUserByEmail.get(normalizedEmail);
if (emailRow && Number(emailRow.id) !== numericUserId) {
const error = new Error('EMAIL_TAKEN');
error.code = 'EMAIL_TAKEN';
throw error;
}
}
let passwordHash = existingUser.password_hash;
let passwordSalt = existingUser.password_salt;
if (typeof password === 'string') {
const trimmedPassword = password.trim();
if (trimmedPassword.length > 0) {
const hashed = hashPassword(trimmedPassword);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
}
} else if (password !== undefined && password !== null) {
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
let colorToPersist = existingUser.avatar_color || DEFAULT_AVATAR_COLOR;
if (avatarColor !== undefined) {
const candidate = String(avatarColor || '').trim();
if (!candidate) {
colorToPersist = DEFAULT_AVATAR_COLOR;
} else {
const normalizedColor = normalizeAvatarColor(candidate);
if (!normalizedColor) {
const error = new Error('INVALID_AVATAR_COLOR');
error.code = 'INVALID_AVATAR_COLOR';
throw error;
}
colorToPersist = normalizedColor;
}
}
const shouldUpdateGroups = groupId !== undefined || groupIds !== undefined;
let normalizedGroupIds = null;
let nextGroups = null;
if (shouldUpdateGroups) {
const incomingGroupIds = Array.isArray(groupIds)
? groupIds
: groupId !== undefined && groupId !== null
? [groupId]
: [];
normalizedGroupIds = Array.from(
new Set(
incomingGroupIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
)
);
const missingGroupIds = [];
nextGroups = [];
normalizedGroupIds.forEach((value) => {
const groupRow = selectGroupById.get(value);
if (!groupRow) {
missingGroupIds.push(value);
} else {
nextGroups.push({ id: value, name: groupRow.name });
}
});
if (missingGroupIds.length > 0) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
error.missingGroupIds = missingGroupIds;
throw error;
}
}
const performUpdate = db.transaction(() => {
updateUserCoreStatement.run(
normalizedUsername,
normalizedEmail,
passwordHash,
passwordSalt,
colorToPersist,
numericUserId
);
if (normalizedGroupIds !== null) {
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
}
});
performUpdate();
const updatedUser = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-aktualisiert',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updatedUser?.username ?? normalizedUsername,
message: `Benutzer "${updatedUser?.username ?? normalizedUsername}" aktualisiert`,
metadata: {
email: updatedUser?.email ?? normalizedEmail ?? null,
groupsUpdated: normalizedGroupIds !== null
? (updatedUser?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? nextGroups)
: undefined,
previousGroups: normalizedGroupIds !== null
? (previousUserRecord?.groups || []).map((group) => ({ id: group.id, name: group.name }))
: undefined,
avatarColor: updatedUser?.avatarColor ?? colorToPersist
},
...resolveActorFields(actor)
});
return updatedUser;
}
const deleteMembershipsStatement = db.prepare(`
DELETE FROM user_group_memberships
WHERE user_id = ?
`);
const deleteUserStatement = db.prepare(`
DELETE FROM users
WHERE id = ?
`);
export function deleteUser(userId, options = {}) {
const actor = options.actor || null;
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 = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership && superuserMembership.has_membership) {
const error = new Error('USER_SUPERUSER_PROTECTED');
error.code = 'USER_SUPERUSER_PROTECTED';
throw error;
}
const performDelete = db.transaction(() => {
deleteMembershipsStatement.run(numericUserId);
deleteUserStatement.run(numericUserId);
});
performDelete();
logEvent({
category: 'benutzer',
eventType: 'benutzer-gelöscht',
action: 'gelöscht',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: existingUser.username ?? `ID ${numericUserId}`,
message: `Benutzer "${existingUser.username ?? numericUserId}" gelöscht`,
metadata: {
email: existingUser.email ?? null
},
...resolveActorFields(actor)
});
}
export function updateUserActiveStatus(userId, isActive, options = {}) {
const actor = options.actor || null;
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 = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership && superuserMembership.has_membership && !isActive) {
const error = new Error('USER_SUPERUSER_PROTECTED');
error.code = 'USER_SUPERUSER_PROTECTED';
throw error;
}
const normalizedIsActive = isActive ? 1 : 0;
updateUserActiveStatement.run(normalizedIsActive, numericUserId);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: normalizedIsActive ? 'benutzer-aktiviert' : 'benutzer-deaktiviert',
action: 'status-aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: normalizedIsActive
? `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktiviert`
: `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" deaktiviert`,
metadata: {
isActive: Boolean(normalizedIsActive)
},
...resolveActorFields(actor)
});
return updated;
}
+2
View File
@@ -10,6 +10,7 @@ import {
} from "@/widgets/layout";
import routes from "@/routes";
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
export function Dashboard() {
@@ -156,6 +157,7 @@ export function Dashboard() {
))
)}
<Route path="users/:userId" element={<UserDetails />} />
<Route path="usergroups/:groupId" element={<UserGroupDetail />} />
</Routes>
<div className="text-blue-gray-600">
<Footer />
+4 -7
View File
@@ -90,27 +90,24 @@ export function SignIn() {
body: JSON.stringify({ identifier: trimmedIdentifier, password: trimmedPassword }),
});
if (response.status === 403) {
navigate("/setup", { replace: true });
return;
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
if (response.status === 401 || payload.error === "INVALID_CREDENTIALS") {
setError("Ungültige Zugangsdaten.");
} else if (payload.error === "MISSING_CREDENTIALS") {
setError("Bitte fülle beide Felder aus.");
} else if (payload.error === "SETUP_REQUIRED") {
} else if (payload.error === "SETUP_REQUIRED" || payload.error === "SUPERUSER_REQUIRED") {
navigate("/setup", { replace: true });
return;
} else if (payload.error === "USER_INACTIVE") {
setError("Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.");
} else {
setError("Anmeldung fehlgeschlagen. Bitte versuche es erneut.");
}
return;
}
await response.json().catch(() => ({}));
navigate("/dashboard/stacks", { replace: true });
} catch (err) {
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
+1
View File
@@ -4,3 +4,4 @@ export * from "@/pages/dashboard/logs";
export * from "@/pages/dashboard/users";
export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/userDetails";
export * from "@/pages/dashboard/userGroupDetail";
File diff suppressed because it is too large Load Diff
@@ -1,88 +0,0 @@
import React from "react";
import {
Typography,
Alert,
Card,
CardHeader,
CardBody,
} from "@material-tailwind/react";
import { InformationCircleIcon } from "@heroicons/react/24/outline";
export function Notifications() {
const [showAlerts, setShowAlerts] = React.useState({
blue: true,
green: true,
orange: true,
red: true,
});
const [showAlertsWithIcon, setShowAlertsWithIcon] = React.useState({
blue: true,
green: true,
orange: true,
red: true,
});
const alerts = ["gray", "green", "orange", "red"];
return (
<div className="mx-auto my-20 flex max-w-screen-lg flex-col gap-8">
<Card>
<CardHeader
color="transparent"
floated={false}
shadow={false}
className="m-0 p-4"
>
<Typography variant="h5" color="blue-gray">
Alerts
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{alerts.map((color) => (
<Alert
key={color}
open={showAlerts[color]}
color={color}
onClose={() => setShowAlerts((current) => ({ ...current, [color]: false }))}
>
A simple {color} alert with an <a href="#">example link</a>. Give
it a click if you like.
</Alert>
))}
</CardBody>
</Card>
<Card>
<CardHeader
color="transparent"
floated={false}
shadow={false}
className="m-0 p-4"
>
<Typography variant="h5" color="blue-gray">
Alerts with Icon
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{alerts.map((color) => (
<Alert
key={color}
open={showAlertsWithIcon[color]}
color={color}
icon={
<InformationCircleIcon strokeWidth={2} className="h-6 w-6" />
}
onClose={() => setShowAlertsWithIcon((current) => ({
...current,
[color]: false,
}))}
>
A simple {color} alert with an <a href="#">example link</a>. Give
it a click if you like.
</Alert>
))}
</CardBody>
</Card>
</div>
);
}
export default Notifications;
-221
View File
@@ -1,221 +0,0 @@
import {
Card,
CardBody,
CardHeader,
CardFooter,
Avatar,
Typography,
Tabs,
TabsHeader,
Tab,
Switch,
Tooltip,
Button,
} from "@material-tailwind/react";
import {
HomeIcon,
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
PencilIcon,
} from "@heroicons/react/24/solid";
import { Link } from "react-router-dom";
import { ProfileInfoCard, MessageCard } from "@/widgets/cards";
import { platformSettingsData, conversationsData, projectsData } from "@/data";
export function Profile() {
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">
<Avatar
src="/img/bruce-mars.jpeg"
alt="bruce-mars"
size="xl"
variant="rounded"
className="rounded-lg shadow-lg shadow-blue-gray-500/40"
/>
<div>
<Typography variant="h5" color="blue-gray" className="mb-1">
Richard Davis
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-600"
>
CEO / Co-Founder
</Typography>
</div>
</div>
<div className="w-96">
<Tabs value="app">
<TabsHeader>
<Tab value="app">
<HomeIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
App
</Tab>
<Tab value="message">
<ChatBubbleLeftEllipsisIcon className="-mt-0.5 mr-2 inline-block h-5 w-5" />
Message
</Tab>
<Tab value="settings">
<Cog6ToothIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
Settings
</Tab>
</TabsHeader>
</Tabs>
</div>
</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>
<Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings
</Typography>
<ul className="flex flex-col gap-6">
{conversationsData.map((props) => (
<MessageCard
key={props.name}
{...props}
action={
<Button variant="text" size="sm">
reply
</Button>
}
/>
))}
</ul>
</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 Profile;
+402 -304
View File
@@ -1,34 +1,25 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
import { useParams } from "react-router-dom";
import {
Card,
CardHeader,
CardBody,
Typography,
Button,
Spinner,
CardFooter,
Switch,
Tooltip,
Avatar,
Select,
Option
Option,
Input,
Switch
} from "@material-tailwind/react";
import {
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 { platformSettingsData } from "@/data";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const _ = AVATAR_COLORS.join(" ");
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
return [];
@@ -67,47 +58,74 @@ const mapUser = (item) => ({
groups: normalizeUserGroups(item?.groups)
});
const extractPrimaryGroupId = (user) => {
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
return null;
}
const firstValid = user.groups
.map((group) => Number(group.id))
.find((id) => Number.isFinite(id) && id > 0);
return Number.isFinite(firstValid) ? firstValid : null;
};
const buildInitialFormValues = (user) => {
if (!user) {
return {
username: "",
email: "",
password: "",
groupId: null,
avatarColor: ""
};
}
const primaryGroupId = extractPrimaryGroupId(user);
return {
username: user.username || "",
email: user.email || "",
password: "",
groupId: Number.isFinite(primaryGroupId) ? primaryGroupId : null,
avatarColor: user.avatarColor || ""
};
};
export function UserDetails() {
const { userId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const [user, setUser] = useState(null);
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
const initialFormValuesRef = useRef(buildInitialFormValues(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 [selectedGroupId, setSelectedGroupId] = useState(null);
const [savingGroup, setSavingGroup] = useState(false);
const [savingUser, setSavingUser] = useState(false);
const [saveError, setSaveError] = useState("");
const maintenanceActive = Boolean(maintenance?.active);
const isSuperuserUser = useMemo(() => {
if (!Array.isArray(user?.groups)) {
return false;
}
return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser");
}, [user]);
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);
setFormValues(buildInitialFormValues(null));
initialFormValuesRef.current = buildInitialFormValues(null);
setHasLoaded(true);
return;
}
@@ -122,7 +140,10 @@ export function UserDetails() {
throw new Error("USER_NOT_FOUND");
}
setUser(item);
setError("");
const initialValues = buildInitialFormValues(item);
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Benutzerdetails konnten nicht geladen werden.";
@@ -136,6 +157,8 @@ export function UserDetails() {
}
setUser(null);
initialFormValuesRef.current = buildInitialFormValues(null);
setFormValues(buildInitialFormValues(null));
setError(message);
showToast({
variant: "error",
@@ -185,340 +208,415 @@ export function UserDetails() {
fetchAvailableGroups();
}, [fetchAvailableGroups]);
const originalGroupId = useMemo(() => {
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
return null;
}
const firstValid = user.groups
.map((group) => Number(group.id))
.find((id) => Number.isFinite(id) && id > 0);
return Number.isFinite(firstValid) ? firstValid : null;
}, [user]);
useEffect(() => {
setSelectedGroupId(originalGroupId);
}, [originalGroupId]);
const hasGroupChanges = useMemo(() => {
if (!hasLoaded) {
const hasChanges = useMemo(() => {
if (!hasLoaded || !user) {
return false;
}
return originalGroupId !== selectedGroupId;
}, [originalGroupId, selectedGroupId, hasLoaded]);
const initial = initialFormValuesRef.current;
if (!initial) {
return false;
}
const initialUsername = initial.username || "";
const currentUsername = formValues.username || "";
const initialEmail = initial.email || "";
const currentEmail = formValues.email || "";
const initialGroupId = Number.isFinite(initial.groupId) ? Number(initial.groupId) : null;
const currentGroupId = Number.isFinite(formValues.groupId) ? Number(formValues.groupId) : null;
const initialAvatar = initial.avatarColor || "";
const currentAvatar = formValues.avatarColor || "";
const passwordChanged = Boolean(formValues.password && formValues.password.trim().length > 0);
return (
initialUsername !== currentUsername ||
initialEmail !== currentEmail ||
(!isSuperuserUser && initialGroupId !== currentGroupId) ||
initialAvatar !== currentAvatar ||
passwordChanged
);
}, [formValues, hasLoaded, user, isSuperuserUser]);
const renderSelectedAvatarLabel = useCallback(
(element) => {
if (element?.props?.children) {
return element.props.children;
}
if (!formValues.avatarColor) {
return "Standardfarbe";
}
return formValues.avatarColor;
},
[formValues.avatarColor]
);
const handleUsernameChange = useCallback((event) => {
const { value } = event.target;
setFormValues((prev) => ({
...prev,
username: value
}));
}, []);
const handleEmailChange = useCallback((event) => {
const { value } = event.target;
setFormValues((prev) => ({
...prev,
email: value
}));
}, []);
const handlePasswordChange = useCallback((event) => {
const { value } = event.target;
setFormValues((prev) => ({
...prev,
password: value
}));
}, []);
const handleGroupChange = useCallback((value) => {
if (!value) {
setSelectedGroupId(null);
setFormValues((prev) => ({
...prev,
groupId: null
}));
return;
}
const numeric = Number(value);
setSelectedGroupId(Number.isFinite(numeric) && numeric > 0 ? numeric : null);
setFormValues((prev) => ({
...prev,
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
}));
}, []);
const handleResetGroup = useCallback(() => {
setSelectedGroupId(originalGroupId);
}, [originalGroupId]);
const handleAvatarColorChange = useCallback((value) => {
setFormValues((prev) => ({
...prev,
avatarColor: value || ""
}));
}, []);
const handleSaveGroup = useCallback(async () => {
if (!user || !hasGroupChanges) {
const handleSaveUser = useCallback(async () => {
if (!user || !hasChanges) {
return;
}
const payloadGroups = selectedGroupId ? [selectedGroupId] : [];
setSavingUser(true);
setSaveError("");
setSavingGroup(true);
setGroupsError("");
try {
const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups });
const payload = {
username: formValues.username,
email: formValues.email,
password: formValues.password,
avatarColor: formValues.avatarColor
};
if (!isSuperuserUser) {
payload.groupId = formValues.groupId;
}
const response = await axios.put(`/api/users/${user.id}`, payload);
const updatedUser = mapUser(response.data?.item || response.data?.user);
setUser(updatedUser);
const nextGroupId = Array.isArray(updatedUser.groups) && updatedUser.groups.length > 0
? Number(updatedUser.groups[0].id)
: null;
setSelectedGroupId(Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null);
const nextInitial = buildInitialFormValues(updatedUser);
initialFormValuesRef.current = { ...nextInitial };
setFormValues(nextInitial);
setGroupsError("");
showToast({
variant: "success",
title: "Globale Rolle aktualisiert",
description: "Die Gruppenzuordnung wurde gespeichert."
title: "Benutzer gespeichert",
description: "Die Änderungen wurden erfolgreich gespeichert."
});
} catch (err) {
const serverError = err.response?.data?.error;
let message = err.message || "Die globale Rolle konnte 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.";
let message = "Die Benutzerdaten konnten nicht gespeichert werden.";
if (serverError === "USERNAME_REQUIRED") {
message = "Bitte einen Benutzernamen angeben.";
} else if (serverError === "USERNAME_TAKEN") {
message = "Der Benutzername wird bereits verwendet.";
} else if (serverError === "INVALID_EMAIL") {
message = "Bitte eine gültige E-Mail-Adresse eingeben.";
} else if (serverError === "EMAIL_TAKEN") {
message = "Die E-Mail-Adresse wird bereits verwendet.";
} else if (serverError === "INVALID_PASSWORD") {
message = "Das Passwort ist ungültig.";
} else if (serverError === "PASSWORD_TOO_SHORT") {
message = "Das Passwort muss mindestens 8 Zeichen enthalten.";
} else if (serverError === "INVALID_AVATAR_COLOR") {
message = "Bitte eine gültige Avatar-Farbe auswählen.";
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Die ausgewählte Gruppe existiert nicht mehr.";
} else if (typeof serverError === "string" && serverError.length > 0) {
message = serverError;
message = "Die ausgewählte Benutzergruppe existiert nicht mehr.";
setGroupsError(message);
}
setGroupsError(message);
setSaveError(message);
showToast({
variant: "error",
title: "Globale Rolle",
title: "Speichern fehlgeschlagen",
description: message
});
} finally {
setSavingGroup(false);
setSavingUser(false);
}
}, [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;
}, [user, hasChanges, formValues, showToast, isSuperuserUser]);
const avatarLabel = useMemo(() => {
const source = (user?.username || user?.email || "").trim();
const source = (formValues.username || formValues.email || "").trim();
if (!source) {
return "?";
}
return source.charAt(0).toUpperCase();
}, [user]);
}, [formValues.username, formValues.email]);
const avatarColorClass = useMemo(() => {
if (formValues.avatarColor) {
return formValues.avatarColor;
}
return user?.avatarColor || "";
}, [formValues.avatarColor, user]);
const filteredGroups = useMemo(() => {
if (!Array.isArray(availableGroups)) {
return [];
}
return availableGroups.filter((group) => (group?.name || "").toLowerCase() !== "superuser");
}, [availableGroups]);
useEffect(() => {
if (isSuperuserUser) {
return;
}
const currentGroupId = formValues.groupId ? Number(formValues.groupId) : null;
const stillExists = filteredGroups.some((group) => Number(group.id) === currentGroupId);
if (!stillExists) {
setFormValues((prev) => ({
...prev,
groupId: null
}));
}
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading;
const avatarSelectDisabled = maintenanceActive || savingUser || !user;
const inputDisabled = maintenanceActive || savingUser || !user;
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
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 className="mt-12 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert.
</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 className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-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 flex-wrap items-center justify-between 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 ${avatarColorClass}`}
aria-label={formValues.username || "Benutzeravatar"}
>
{avatarLabel}
</div>
<div>
<Typography variant="h5" color="blue-gray">
{formValues.username || ""}
</Typography>
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
{formValues.email || ""}
</Typography>
</div>
</div>
{hasChanges && (
<Button
color="green"
className="normal-case"
onClick={handleSaveUser}
disabled={maintenanceActive || savingUser}
>
{savingUser ? "Speichert ..." : "Änderungen speichern"}
</Button>
)}
</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",
}}
/>
))}
{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>
)}
{saveError && !loading && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{saveError}
</div>
)}
<div className="grid-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-4">
Allgemeine Einstellungen
</Typography>
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Benutzername
</Typography>
<Input
value={formValues.username}
onChange={handleUsernameChange}
placeholder="Benutzername"
disabled={inputDisabled}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
}}
/>
</div>
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
E-Mail-Adresse
</Typography>
<Input
type="email"
value={formValues.email}
onChange={handleEmailChange}
placeholder="benutzer@example.com"
disabled={inputDisabled}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
}}
/>
</div>
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Neues Passwort
</Typography>
<Input
type="password"
value={formValues.password}
onChange={handlePasswordChange}
placeholder="Passwort setzen"
disabled={inputDisabled}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
}}
/>
<Typography className="mt-1 text-xs text-blue-gray-400">
Das Passwort wird nur geändert, wenn ein neuer Wert eingetragen wird.
</Typography>
</div>
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Globale Rolle
</Typography>
{groupsError && !isSuperuserUser && (
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
</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">
Globale Rolle
</Typography>
<div className="flex items-center gap-2">
<Button
variant="text"
size="sm"
color="blue-gray"
className="normal-case"
onClick={handleResetGroup}
disabled={!hasGroupChanges || savingGroup || !user}
>
Änderungen verwerfen
</Button>
<Button
size="sm"
color="green"
className="normal-case"
onClick={handleSaveGroup}
disabled={maintenanceActive || savingGroup || !hasGroupChanges || !user}
>
{savingGroup ? "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 sm:flex-row sm:items-end">
<div className="sm:flex-1">
)}
{isSuperuserUser ? (
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
Die Rolle für den Superuser kann nicht geändert werden.
</Typography>
) : 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>
) : filteredGroups.length === 0 ? (
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
Es sind keine weiteren Benutzergruppen vorhanden. Bitte lege zunächst eine neue Gruppe an.
</Typography>
) : (
<Select
label="Benutzergruppe wählen"
value={selectValue}
value={groupSelectValue}
onChange={handleGroupChange}
disabled={selectDisabled}
variant="outlined"
>
{availableGroups.map((group) => (
{filteredGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
</div>
)}
</div>
)}
{!(groupsLoading && availableGroups.length === 0) && (
<Typography variant="small" className="text-sm text-stormGrey-500">
{selectedGroupLabel
? `Aktuell zugewiesene Gruppe: ${selectedGroupLabel}`
: "Dem Benutzer ist aktuell keine Gruppe zugewiesen."}
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Avatar-Farbe
</Typography>
<Select
label="Avatar-Farbe auswählen"
variant="outlined"
value={formValues.avatarColor}
onChange={handleAvatarColorChange}
disabled={avatarSelectDisabled}
selected={renderSelectedAvatarLabel}
>
<Option value="">
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded border border-blue-gray-100" />
<span className="text-xs">Keine</span>
</span>
</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>
<div>
<Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings
</Typography>
)}
</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"
>
<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>
<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 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>
</CardFooter>
</Card>
)
)}
</div>
))}
</div>
</div>
</div>
</div>
</CardBody>
</Card>
</CardBody>
</Card></div>
</>
);
}
@@ -0,0 +1,425 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
import { useParams } from "react-router-dom";
import {
Card,
CardBody,
Typography,
Button,
Spinner,
Select,
Option,
Input,
Chip
} from "@material-tailwind/react";
import { useToast } from "@/components/ToastProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
const _ = AVATAR_COLORS.join(" ");
const mapGroup = (item) => ({
id: item?.id ?? null,
name: item?.name || "",
description: item?.description || "",
avatarColor: item?.avatarColor || null,
createdAt: item?.createdAt || null,
updatedAt: item?.updatedAt || null,
memberCount: Number.isFinite(Number(item?.memberCount)) ? Number(item.memberCount) : 0,
members: Array.isArray(item?.members)
? item.members
.map((member) => ({
id: member?.id ?? null,
username: member?.username || ""
}))
.filter((member) => member.username)
: []
});
const buildInitialFormValues = (group) => {
if (!group) {
return {
name: "",
description: "",
avatarColor: ""
};
}
return {
name: group.name || "",
description: group.description || "",
avatarColor: group.avatarColor || ""
};
};
export function UserGroupDetail() {
const { groupId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const [group, setGroup] = useState(null);
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
const initialFormValuesRef = useRef(buildInitialFormValues(null));
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [hasLoaded, setHasLoaded] = useState(false);
const [savingGroup, setSavingGroup] = useState(false);
const [saveError, setSaveError] = useState("");
const maintenanceActive = Boolean(maintenance?.active);
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
const numericGroupId = useMemo(() => {
const candidate = Number(groupId);
return Number.isFinite(candidate) ? candidate : null;
}, [groupId]);
const fetchGroupDetails = useCallback(async () => {
if (!numericGroupId) {
setError("Ungültige Gruppen-ID.");
setGroup(null);
setFormValues(buildInitialFormValues(null));
initialFormValuesRef.current = buildInitialFormValues(null);
setHasLoaded(true);
return;
}
setLoading(true);
setError("");
try {
const response = await axios.get(`/api/groups/${numericGroupId}`);
const item = mapGroup(response.data?.item);
if (!item.id) {
throw new Error("GROUP_NOT_FOUND");
}
setGroup(item);
const initialValues = buildInitialFormValues(item);
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Gruppendetails konnten nicht geladen werden.";
if (serverError === "GROUP_NOT_FOUND") {
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
} else if (serverError === "INVALID_GROUP_ID") {
message = "Die angegebene Gruppen-ID ist ungültig.";
} else if (err.response?.status === 404) {
message = "Die angeforderte Benutzergruppe existiert nicht.";
}
setGroup(null);
initialFormValuesRef.current = buildInitialFormValues(null);
setFormValues(buildInitialFormValues(null));
setError(message);
showToast({
variant: "error",
title: "Fehler beim Laden",
description: message
});
} finally {
setLoading(false);
setHasLoaded(true);
}
}, [numericGroupId, showToast]);
useEffect(() => {
fetchGroupDetails();
}, [fetchGroupDetails]);
const hasChanges = useMemo(() => {
if (!hasLoaded || !group) {
return false;
}
const initial = initialFormValuesRef.current;
if (!initial) {
return false;
}
const initialName = initial.name || "";
const currentName = formValues.name || "";
const initialDescription = initial.description || "";
const currentDescription = formValues.description || "";
const initialAvatar = initial.avatarColor || "";
const currentAvatar = formValues.avatarColor || "";
return (
initialName !== currentName ||
initialDescription !== currentDescription ||
initialAvatar !== currentAvatar
);
}, [formValues, hasLoaded, group]);
const handleNameChange = useCallback((event) => {
const { value } = event.target;
setFormValues((prev) => ({
...prev,
name: value
}));
}, []);
const handleDescriptionChange = useCallback((event) => {
const { value } = event.target;
setFormValues((prev) => ({
...prev,
description: value
}));
}, []);
const handleAvatarColorChange = useCallback((value) => {
setFormValues((prev) => ({
...prev,
avatarColor: value || ""
}));
}, []);
const handleSaveGroup = useCallback(async () => {
if (!group || !hasChanges) {
return;
}
setSavingGroup(true);
setSaveError("");
try {
const payload = {
avatarColor: formValues.avatarColor
};
if (!isSuperuserGroup) {
payload.name = formValues.name;
payload.description = formValues.description;
}
const response = await axios.put(`/api/groups/${group.id}`, payload);
const updated = mapGroup(response.data?.item || response.data?.group);
setGroup(updated);
const nextInitial = buildInitialFormValues(updated);
initialFormValuesRef.current = { ...nextInitial };
setFormValues(nextInitial);
showToast({
variant: "success",
title: "Gruppe gespeichert",
description: "Die Änderungen wurden erfolgreich gespeichert."
});
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Die Gruppe konnte nicht gespeichert werden.";
if (serverError === "GROUP_NAME_REQUIRED") {
message = "Bitte einen Gruppennamen angeben.";
} else if (serverError === "GROUP_NAME_TAKEN") {
message = "Der Gruppenname wird bereits verwendet.";
} else if (serverError === "INVALID_AVATAR_COLOR") {
message = "Bitte eine gültige Avatar-Farbe auswählen.";
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Die Benutzergruppe wurde nicht gefunden.";
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
message = "Für die Superuser-Gruppe kann nur die Avatar-Farbe angepasst werden.";
}
setSaveError(message);
showToast({
variant: "error",
title: "Speichern fehlgeschlagen",
description: message
});
} finally {
setSavingGroup(false);
}
}, [group, hasChanges, formValues, showToast, isSuperuserGroup]);
const avatarLabel = useMemo(() => {
const source = (formValues.name || formValues.description || "").trim();
if (!source) {
return "?";
}
return source.charAt(0).toUpperCase();
}, [formValues.name, formValues.description]);
const avatarColorClass = useMemo(() => {
if (formValues.avatarColor) {
return formValues.avatarColor;
}
return group?.avatarColor || "";
}, [formValues.avatarColor, group]);
const selectDisabled = maintenanceActive || savingGroup || !group;
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup;
const selectValue = formValues.avatarColor || "";
return (
<>
<div className="mt-12 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert.
</div>
)}
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-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 flex-wrap items-center justify-between 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 ${avatarColorClass}`}
aria-label={formValues.name || "Gruppenavatar"}
>
{avatarLabel}
</div>
<div>
<Typography variant="h5" color="blue-gray">
{formValues.name || ""}
</Typography>
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
Gruppen-ID: {group?.id ?? ""}
</Typography>
</div>
</div>
{hasChanges && (
<Button
color="green"
className="normal-case"
onClick={handleSaveGroup}
disabled={maintenanceActive || savingGroup}
>
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
</Button>
)}
</div>
{loading && !group && (
<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>Gruppendaten 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>
)}
{saveError && !loading && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{saveError}
</div>
)}
<div className="grid-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-4">
Gruppendaten
</Typography>
{(maintenanceActive || isSuperuserGroup) && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{maintenanceActive
? "Wartungsmodus aktiv Änderungen sind deaktiviert."
: "Systemgruppe Name und Beschreibung sind geschützt."}
</div>
)}
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Gruppenname
</Typography>
<Input
value={formValues.name}
onChange={handleNameChange}
placeholder="Gruppenname"
disabled={inputDisabled}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
}}
/>
</div>
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Beschreibung
</Typography>
<Input
value={formValues.description}
onChange={handleDescriptionChange}
placeholder="Beschreibung der Gruppe"
disabled={inputDisabled}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
}}
/>
</div>
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Avatar-Farbe
</Typography>
<Select
label="Avatar-Farbe auswählen"
variant="outlined"
value={selectValue}
onChange={handleAvatarColorChange}
disabled={selectDisabled}
selected={(element) => {
if (element?.props?.children) {
return element.props.children;
}
if (!formValues.avatarColor) {
return "Standardfarbe";
}
return formValues.avatarColor;
}}
>
<Option value="">
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded border border-blue-gray-100" />
<span className="text-xs">Keine</span>
</span>
</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>
<div>
<Typography variant="h6" color="blue-gray" className="mb-4">
Mitglieder
</Typography>
{!group || group.members.length === 0 ? (
<Typography className="text-sm text-stormGrey-500">
Aktuell sind keine Benutzer dieser Gruppe zugeordnet.
</Typography>
) : (
<div className="flex flex-wrap gap-2">
{group.members.map((member) => (
<Chip
key={`${group.id}-${member.id}-${member.username}`}
value={member.username}
size="sm"
color="blue-gray"
variant="ghost"
/>
))}
</div>
)}
<div className="mt-6 text-xs text-stormGrey-400">
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
</div>
</div>
</div>
</CardBody>
</Card></div>
</>
);
}
export default UserGroupDetail;
+116 -35
View File
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useNavigate } from "react-router-dom";
import {
Card,
CardHeader,
@@ -24,10 +25,12 @@ export function Usergroups() {
const [newGroupDescription, setNewGroupDescription] = useState("");
const [createGroupError, setCreateGroupError] = useState("");
const [creatingGroup, setCreatingGroup] = useState(false);
const [deletingGroupId, setDeletingGroupId] = useState(null);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const navigate = useNavigate();
const {
page,
@@ -54,6 +57,7 @@ export function Usergroups() {
id: item.id,
name: item.name || "",
description: item.description || "",
avatarColor: item.avatarColor || null,
memberCount: Number(item.memberCount) || 0,
members: Array.isArray(item.members)
? item.members.map((member) => ({
@@ -233,8 +237,86 @@ export function Usergroups() {
const createGroupDisabled = maintenanceActive || creatingGroup;
const handleDeleteGroup = useCallback(async (group) => {
if (maintenanceActive || !group?.id) {
return;
}
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
if (isSuperuserGroup) {
showToast({
variant: "error",
title: "Löschen nicht möglich",
description: "Die Superuser-Gruppe kann nicht gelöscht werden."
});
return;
}
const memberCount = Number(group.memberCount) || 0;
if (memberCount > 0) {
showToast({
variant: "error",
title: "Löschen nicht möglich",
description: "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst."
});
return;
}
const confirmation = window.confirm(`Benutzergruppe "${group.name}" wirklich löschen?`);
if (!confirmation) {
return;
}
const numericId = Number(group.id);
if (!Number.isFinite(numericId) || numericId <= 0) {
showToast({
variant: "error",
title: "Löschen fehlgeschlagen",
description: "Ungültige Gruppen-ID."
});
return;
}
setDeletingGroupId(numericId);
try {
await axios.delete(`/api/groups/${numericId}`);
showToast({
variant: "success",
title: "Gruppe gelöscht",
description: `Die Benutzergruppe "${group.name}" wurde entfernt.`
});
fetchGroups();
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Die Benutzergruppe konnte nicht gelöscht werden.";
if (serverError === "INVALID_GROUP_ID") {
message = "Die Gruppen-ID ist ungültig.";
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Die Benutzergruppe wurde bereits entfernt.";
} else if (serverError === "GROUP_HAS_MEMBERS") {
message = "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst.";
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
message = "Die Superuser-Gruppe kann nicht gelöscht werden.";
}
showToast({
variant: "error",
title: "Löschen fehlgeschlagen",
description: message
});
} finally {
setDeletingGroupId(null);
}
}, [maintenanceActive, showToast, fetchGroups]);
return (
<div className="mt-12">
<div className="mt-12 mb-8 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
</div>
)}
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
@@ -245,13 +327,6 @@ export function Usergroups() {
</Typography>
</CardHeader>
<CardBody className="pt-0">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
</div>
)}
<div className="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
@@ -335,7 +410,6 @@ export function Usergroups() {
<th className="px-6 py-4 font-semibold">Gruppenname</th>
<th className="px-6 py-4 font-semibold">Beschreibung</th>
<th className="px-6 py-4 font-semibold">Mitglieder</th>
<th className="px-6 py-4 font-semibold">Anzahl</th>
<th className="px-6 py-4 font-semibold">Erstellt am</th>
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
<th className="px-6 py-4 font-semibold">Aktionen</th>
@@ -358,6 +432,7 @@ export function Usergroups() {
) : (
paginatedGroups.map((group, index) => {
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
return (
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
@@ -375,29 +450,10 @@ export function Usergroups() {
</Typography>
</td>
<td className="px-6 py-4">
{group.members.length > 0 ? (
<div className="flex flex-wrap gap-2">
{group.members.map((member) => (
<Chip
key={`${group.id}-${member.id}-${member.username}`}
value={member.username}
size="sm"
color="blue-gray"
variant="ghost"
/>
))}
</div>
) : (
<span className="text-stormGrey-400">Keine Mitglieder</span>
)}
</td>
<td className="px-6 py-4">
<Chip
value={group.memberCount.toLocaleString("de-DE")}
size="sm"
color={group.memberCount > 0 ? "green" : "blue-gray"}
variant="ghost"
/>
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
{group.memberCount.toLocaleString("de-DE")}
</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">
@@ -410,9 +466,34 @@ export function Usergroups() {
</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>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
<Button
size="sm"
variant="outlined"
color="blue-gray"
onClick={() => navigate(`/dashboard/usergroups/${group.id}`)}
>
Details
</Button>
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteGroup(group)}
disabled={
maintenanceActive ||
deletingGroupId === group.id ||
Number(group.memberCount) > 0 ||
isSuperuserGroup
}
>
{isSuperuserGroup
? ""
: deletingGroupId === group.id
? "Löscht ..."
: "Löschen"}
</Button>
</div>
</td>
</tr>
);
+274 -201
View File
@@ -15,7 +15,6 @@ import { useNavigate } from "react-router-dom";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
@@ -55,9 +54,10 @@ export function Users() {
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 [deletingUserId, setDeletingUserId] = useState(null);
const [togglingUserId, setTogglingUserId] = useState(null);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
@@ -123,6 +123,7 @@ export function Users() {
name: item.name || ""
}))
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
.filter((group) => group.name.toLowerCase() !== "superuser")
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
setAvailableGroups(normalized);
} catch (err) {
@@ -243,10 +244,140 @@ export function Users() {
setNewEmail("");
setNewPassword("");
setNewGroupId("");
setNewAvatarColor("");
setCreateError("");
}, []);
const handleDeleteUser = useCallback(async (user) => {
if (maintenanceActive || !user?.id) {
return;
}
const isSuperuserUser = Array.isArray(user?.groups)
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
: false;
if (isSuperuserUser) {
showToast({
variant: "error",
title: "Löschen nicht möglich",
description: "Der Superuser kann nicht gelöscht werden."
});
return;
}
const confirmation = window.confirm(`Benutzer "${user.username}" wirklich löschen?`);
if (!confirmation) {
return;
}
const numericId = Number(user.id);
if (!Number.isFinite(numericId) || numericId <= 0) {
showToast({
variant: "error",
title: "Löschen fehlgeschlagen",
description: "Ungültige Benutzer-ID."
});
return;
}
setDeletingUserId(numericId);
try {
await axios.delete(`/api/users/${numericId}`);
showToast({
variant: "success",
title: "Benutzer gelöscht",
description: `Der Benutzer "${user.username}" wurde entfernt.`
});
fetchUsers();
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Der Benutzer konnte nicht gelöscht werden.";
if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig.";
} else if (serverError === "USER_NOT_FOUND") {
message = "Der Benutzer wurde bereits entfernt.";
} else if (serverError === "USER_SUPERUSER_PROTECTED") {
message = "Der Superuser kann nicht gelöscht werden.";
}
showToast({
variant: "error",
title: "Löschen fehlgeschlagen",
description: message
});
} finally {
setDeletingUserId(null);
}
}, [maintenanceActive, showToast, fetchUsers]);
const handleToggleUserStatus = useCallback(async (user) => {
if (!user?.id) {
return;
}
if (maintenanceActive) {
showToast({
variant: "error",
title: "Aktion nicht möglich",
description: "Im Wartungsmodus können keine Statusänderungen durchgeführt werden."
});
return;
}
const isSuperuserUser = Array.isArray(user?.groups)
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
: false;
if (isSuperuserUser && user.isActive) {
showToast({
variant: "error",
title: "Änderung nicht möglich",
description: "Der Superuser kann nicht deaktiviert werden."
});
return;
}
const numericId = Number(user.id);
if (!Number.isFinite(numericId) || numericId <= 0) {
showToast({
variant: "error",
title: "Statusänderung fehlgeschlagen",
description: "Ungültige Benutzer-ID."
});
return;
}
const nextActive = !user.isActive;
setTogglingUserId(numericId);
try {
await axios.put(`/api/users/${numericId}/active`, { isActive: nextActive });
fetchUsers();
showToast({
variant: "success",
title: "Status aktualisiert",
description: `Der Benutzer wurde ${nextActive ? "aktiviert" : "deaktiviert"}.`
});
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Der Benutzerstatus konnte nicht geändert werden.";
if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig.";
} else if (serverError === "USER_NOT_FOUND") {
message = "Der Benutzer wurde nicht gefunden.";
} else if (serverError === "USER_SUPERUSER_PROTECTED") {
message = "Der Superuser kann nicht deaktiviert werden.";
}
showToast({
variant: "error",
title: "Statusänderung fehlgeschlagen",
description: message
});
} finally {
setTogglingUserId(null);
}
}, [maintenanceActive, showToast, fetchUsers]);
const handleCreateUser = useCallback(async () => {
if (maintenanceActive) {
return;
@@ -277,9 +408,6 @@ export function Users() {
if (trimmedEmail) {
payload.email = trimmedEmail;
}
if (newAvatarColor) {
payload.avatarColor = newAvatarColor;
}
const response = await axios.post("/api/users", payload);
resetNewUserForm();
fetchUsers();
@@ -330,16 +458,14 @@ export function Users() {
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 hasSelectableGroups = availableGroups.length > 0;
const createDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const renderSelectedGroup = useCallback(
(element) => {
if (element && element.props && element.props.children) {
@@ -354,26 +480,13 @@ export function Users() {
[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 (
<div className="mt-12">
<div className="mt-12 mb-8 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
</div>
)}
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
@@ -384,177 +497,111 @@ export function Users() {
</Typography>
</CardHeader>
<CardBody className="pt-0">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
</div>
)}
<div className="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="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
label="Suchen nach Name, E-Mail oder Gruppe"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !users.length}
crossOrigin=""
/>
</div> */}
{/* {searchQuery && (
<Button
variant="text"
color="blue-gray"
onClick={handleClearSearch}
className="w-full md:w-auto"
>
Suche zurücksetzen
</Button>
)} */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
<div className="md:flex-1">
{hasSelectableGroups ? (
<>
<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="Suchen nach Name, E-Mail oder Gruppe"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !users.length}
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=""
/>
</div>
<div className="md:mt-0 mt-8 md:flex-1">
<Select
variant="static"
label="Einträge pro Seite"
onChange={noop}
value={perPage}
label="Globale Rolle"
variant="outlined"
value={newGroupId}
onChange={setNewGroupId}
disabled={groupSelectDisabled}
selected={renderSelectedGroup}
>
{perPageOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
<Option value="">Bitte auswählen</Option>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
</div>
<div className="mt-4 text-sm text-stormGrey-500">
{groupsLoading
? "Benutzergruppen werden geladen ..."
: ""}
</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 className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
</div>
)}
</div>
<div className="mb-8">
<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>
@@ -594,6 +641,9 @@ export function Users() {
) : (
paginatedUsers.map((user, index) => {
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
const isSuperuserUser = Array.isArray(user?.groups)
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
: false;
return (
<tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
@@ -624,12 +674,20 @@ export function Users() {
)}
</td>
<td className="px-6 py-4">
<Chip
value={user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm"
color={user.isActive ? "green" : "red"}
variant="ghost"
/>
<button
type="button"
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => handleToggleUserStatus(user)}
disabled={maintenanceActive || togglingUserId === user.id || (Array.isArray(user.groups) && user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))}
>
<Chip
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm"
color={user.isActive ? "green" : "red"}
variant="ghost"
className="pointer-events-none"
/>
</button>
</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
@@ -642,14 +700,29 @@ export function Users() {
</Typography>
</td>
<td className="px-6 py-4">
<Button
size="sm"
variant="outlined"
color="blue-gray"
onClick={() => navigate(`/dashboard/users/${user.id}`)}
>
Details
</Button>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
<Button
size="sm"
variant="outlined"
color="blue-gray"
onClick={() => navigate(`/dashboard/users/${user.id}`)}
>
Details
</Button>
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteUser(user)}
disabled={maintenanceActive || deletingUserId === user.id || isSuperuserUser}
>
{isSuperuserUser
? ""
: deletingUserId === user.id
? "Löscht ..."
: "Löschen"}
</Button>
</div>
</td>
</tr>
);