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;'); db.exec('PRAGMA foreign_keys = ON;');
const createRedeployLogsTable = ` const createEventLogsTable = `
CREATE TABLE IF NOT EXISTS redeploy_logs ( CREATE TABLE IF NOT EXISTS event_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
stack_id TEXT NOT NULL, category TEXT NOT NULL,
stack_name TEXT NOT NULL, event_type TEXT,
status TEXT NOT NULL, 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, message TEXT,
endpoint INTEGER, metadata TEXT
redeploy_type TEXT
); );
`; `;
@@ -43,6 +54,7 @@ CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT, description TEXT,
avatar_color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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 serializeMetadata = (payload) => {
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all(); if (!payload || typeof payload !== 'object' || !Object.keys(payload).length) {
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type'); return null;
if (!hasRedeployType) {
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
console.log('️ redeploy_type column hinzugefügt');
} }
} catch (err) { try {
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message); 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); db.exec(createSettingsTable);
console.log('✅ settings table ready'); console.log('✅ settings table ready');
@@ -198,7 +302,13 @@ try {
const hasAvatarColor = userColumns.some((column) => column.name === 'avatar_color'); const hasAvatarColor = userColumns.some((column) => column.name === 'avatar_color');
if (!hasAvatarColor) { if (!hasAvatarColor) {
db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT'); db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT');
console.log('️ avatar_color column hinzugefügt'); console.log('️ avatar_color column 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'); const emailColumn = userColumns.find((column) => column.name === 'email');
if (emailColumn && emailColumn.notnull === 1) { 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 { 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(` const selectGroupsWithMembers = db.prepare(`
SELECT SELECT
g.id, g.id,
g.name, g.name,
g.description, g.description,
g.avatar_color,
g.created_at, g.created_at,
g.updated_at, g.updated_at,
COUNT(DISTINCT u.id) AS member_count, COUNT(DISTINCT u.id) AS member_count,
@@ -21,6 +29,7 @@ const selectGroupWithMembersById = db.prepare(`
g.id, g.id,
g.name, g.name,
g.description, g.description,
g.avatar_color,
g.created_at, g.created_at,
g.updated_at, g.updated_at,
COUNT(DISTINCT u.id) AS member_count, COUNT(DISTINCT u.id) AS member_count,
@@ -32,6 +41,12 @@ const selectGroupWithMembersById = db.prepare(`
GROUP BY g.id GROUP BY g.id
`); `);
const selectGroupByIdBasic = db.prepare(`
SELECT id, name, description, avatar_color
FROM user_groups
WHERE id = ?
`);
const selectGroupIdByName = db.prepare(` const selectGroupIdByName = db.prepare(`
SELECT id SELECT id
FROM user_groups FROM user_groups
@@ -40,8 +55,28 @@ const selectGroupIdByName = db.prepare(`
`); `);
const insertGroupStatement = db.prepare(` const insertGroupStatement = db.prepare(`
INSERT INTO user_groups (name, description, created_at, updated_at) INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) 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) => { const parseMembers = (rawPairs) => {
@@ -66,6 +101,7 @@ const sanitizeGroup = (row) => ({
id: row.id, id: row.id,
name: row.name, name: row.name,
description: row.description || '', description: row.description || '',
avatarColor: row.avatar_color || null,
createdAt: row.created_at || null, createdAt: row.created_at || null,
updatedAt: row.updated_at || null, updatedAt: row.updated_at || null,
memberCount: Number(row.member_count) || 0, memberCount: Number(row.member_count) || 0,
@@ -77,6 +113,15 @@ export function listGroups() {
return rows.map(sanitizeGroup); 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 }) { export function createGroup({ name, description }) {
const normalizedName = typeof name === 'string' ? name.trim() : ''; const normalizedName = typeof name === 'string' ? name.trim() : '';
if (!normalizedName) { if (!normalizedName) {
@@ -93,8 +138,205 @@ export function createGroup({ name, description }) {
} }
const normalizedDescription = typeof description === 'string' ? description.trim() : null; 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 groupId = Number(insertResult.lastInsertRowid);
const row = selectGroupWithMembersById.get(groupId); 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 verifyPassword
} from './auth/superuser.js'; } from './auth/superuser.js';
import { import {
logRedeployEvent, logEvent,
buildLogFilter, buildEventLogFilter,
deleteLogById, deleteEventLogById,
deleteLogsByFilters, deleteEventLogsByFilters,
exportLogsByFilters exportEventLogsByFilters
} from './db/redeployLogs.js'; } from './logging/eventLogs.js';
import { getSetting, setSetting, deleteSetting } from './db/settings.js'; import { getSetting, setSetting, deleteSetting } from './db/settings.js';
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js'; import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
import { import {
@@ -45,8 +45,8 @@ import {
removeServer, removeServer,
setServerApiKey setServerApiKey
} from './setup/index.js'; } from './setup/index.js';
import { listUsers, getUserById, updateUserGroups, createUser } from './users/index.js'; import { listUsers, getUserById, updateUserGroups, createUser, updateUserDetails, deleteUser, updateUserActiveStatus } from './users/index.js';
import { listGroups, createGroup } from './groups/index.js'; import { listGroups, createGroup, getGroupById, updateGroupDetails, deleteGroup } from './groups/index.js';
dotenv.config(); dotenv.config();
@@ -301,6 +301,53 @@ const REDEPLOY_TYPES = {
MAINTENANCE: 'Wartung' 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 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_SCRIPT_SETTING_KEY = 'portainer_update_script';
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config'; const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
@@ -942,22 +989,40 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
}); });
maintenanceActivated = true; maintenanceActivated = true;
logRedeployEvent({ logEvent({
stackId: 'portainer', category: 'wartung',
stackName: 'Portainer', eventType: 'portainer-update',
status: 'started', action: 'aktualisieren',
status: 'gestartet',
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`, message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.MAINTENANCE targetVersion: targetVersion ?? null,
scriptSource
},
source: 'system'
}); });
logRedeployEvent({ logEvent({
stackId: 'maintenance', category: 'wartung',
stackName: 'StackPulse Wartung', eventType: 'Wartungsmodus',
status: 'started', action: 'aktivieren',
status: 'gestartet',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Wartungsmodus aktiviert (Portainer Update)', message: 'Wartungsmodus aktiviert (Portainer Update)',
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.MAINTENANCE reason: 'portainer-update',
scriptSource
},
source: 'system'
}); });
updatePortainerState({ updatePortainerState({
@@ -981,13 +1046,21 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
const statusAfter = await fetchPortainerStatusSummary().catch(() => null); const statusAfter = await fetchPortainerStatusSummary().catch(() => null);
const finalVersion = statusAfter?.currentVersion ?? null; const finalVersion = statusAfter?.currentVersion ?? null;
logRedeployEvent({ logEvent({
stackId: 'portainer', category: 'wartung',
stackName: 'Portainer', eventType: 'portainer-update',
status: 'success', action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`, message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.MAINTENANCE resultVersion: finalVersion ?? null
},
source: 'system'
}); });
updatePortainerState({ updatePortainerState({
@@ -1005,24 +1078,37 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
if (maintenanceActivated || maintenanceState?.active) { if (maintenanceActivated || maintenanceState?.active) {
deactivateMaintenanceMode({ message: 'Portainer Update abgeschlossen' }); deactivateMaintenanceMode({ message: 'Portainer Update abgeschlossen' });
maintenanceActivated = false; maintenanceActivated = false;
logRedeployEvent({ logEvent({
stackId: 'maintenance', category: 'wartung',
stackName: 'StackPulse Wartung', eventType: 'Wartungsmodus',
status: 'success', action: 'deaktivieren',
status: 'erfolgreich',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Wartungsmodus deaktiviert', message: 'Wartungsmodus deaktiviert',
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.MAINTENANCE reason: 'portainer-update'
},
source: 'system'
}); });
} }
} catch (err) { } catch (err) {
const message = err?.message || 'Portainer Update fehlgeschlagen'; const message = err?.message || 'Portainer Update fehlgeschlagen';
logRedeployEvent({ logEvent({
stackId: 'portainer', category: 'wartung',
stackName: 'Portainer', eventType: 'portainer-update',
status: 'error', action: 'aktualisieren',
status: 'fehler',
entityType: 'service',
entityId: 'portainer',
entityName: 'Portainer',
contextType: 'endpoint',
contextId: String(endpointId),
message, message,
endpoint: endpointId, source: 'system'
redeployType: REDEPLOY_TYPES.MAINTENANCE
}); });
updatePortainerState({ updatePortainerState({
@@ -1038,13 +1124,21 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
if (maintenanceActivated || isMaintenanceModeActive()) { if (maintenanceActivated || isMaintenanceModeActive()) {
deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' }); deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' });
logRedeployEvent({ logEvent({
stackId: 'maintenance', category: 'wartung',
stackName: 'StackPulse Wartung', eventType: 'Wartungsmodus',
status: 'error', action: 'deaktivieren',
status: 'fehler',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'endpoint',
contextId: String(endpointId),
message: 'Wartungsmodus deaktiviert (Fehler)', message: 'Wartungsmodus deaktiviert (Fehler)',
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.MAINTENANCE reason: 'portainer-update'
},
source: 'system'
}); });
} }
} finally { } finally {
@@ -1191,12 +1285,12 @@ const redeployStackById = async (stackId, redeployType) => {
phase: REDEPLOY_PHASES.STARTED phase: REDEPLOY_PHASES.STARTED
}); });
logRedeployEvent({ logStackEvent({
stackId: targetId, stackId: targetId,
stackName: targetName, stackName: targetName,
status: 'started', status: 'started',
message: messages.started, message: messages.started,
endpoint: stack.EndpointId, endpointId: stack.EndpointId,
redeployType redeployType
}); });
@@ -1271,12 +1365,12 @@ const redeployStackById = async (stackId, redeployType) => {
phase: REDEPLOY_PHASES.SUCCESS phase: REDEPLOY_PHASES.SUCCESS
}); });
logRedeployEvent({ logStackEvent({
stackId: targetId, stackId: targetId,
stackName: targetName, stackName: targetName,
status: 'success', status: 'success',
message: messages.success, message: messages.success,
endpoint: stack.EndpointId, endpointId: stack.EndpointId,
redeployType redeployType
}); });
@@ -1293,12 +1387,12 @@ const redeployStackById = async (stackId, redeployType) => {
message: errorMessage message: errorMessage
}); });
logRedeployEvent({ logStackEvent({
stackId: fallbackId, stackId: fallbackId,
stackName: fallbackName, stackName: fallbackName,
status: 'error', status: 'error',
message: errorMessage, message: errorMessage,
endpoint: stack?.EndpointId || endpointId, endpointId: stack?.EndpointId || endpointId,
redeployType redeployType
}); });
@@ -1621,10 +1715,14 @@ app.post('/api/auth/login', (req, res) => {
} }
const user = findUserByIdentifier(identifier); const user = findUserByIdentifier(identifier);
if (!user || !user.is_active) { if (!user) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); 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); const passwordValid = verifyPassword(password, user.password_hash, user.password_salt);
if (!passwordValid) { if (!passwordValid) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); 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) => { app.put('/api/users/:userId/groups', (req, res) => {
const { userId } = req.params; const { userId } = req.params;
const numericId = Number(userId); 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) => { app.get('/api/groups', (req, res) => {
try { try {
const groups = listGroups(); 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) => { app.post('/api/groups', (req, res) => {
const { name, description } = req.body ?? {}; const { name, description } = req.body ?? {};
try { 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 // Superuser Setup
app.get('/api/auth/superuser/status', (req, res) => { app.get('/api/auth/superuser/status', (req, res) => {
const exists = hasSuperuser(); 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' }); return res.status(400).json({ error: 'Keine passenden Duplikate gefunden' });
} }
logRedeployEvent({ logStackEvent({
stackId: target.canonical.Id, stackId: target.canonical.Id,
stackName: target.canonical.Name, stackName: target.canonical.Name,
status: 'started', status: 'started',
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`, message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
endpoint: target.canonical.EndpointId, endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
duplicateIds: duplicateIds
}
}); });
const results = []; const results = [];
@@ -2238,13 +2539,16 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
if (errors.length) { if (errors.length) {
const failedIds = errors.map((entry) => entry.id).join(', '); const failedIds = errors.map((entry) => entry.id).join(', ');
logRedeployEvent({ logStackEvent({
stackId: target.canonical.Id, stackId: target.canonical.Id,
stackName: target.canonical.Name, stackName: target.canonical.Name,
status: 'error', status: 'error',
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`, message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
endpoint: target.canonical.EndpointId, endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
duplicateIds: duplicateIds
}
}); });
return res.status(500).json({ 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, stackId: target.canonical.Id,
stackName: target.canonical.Name, stackName: target.canonical.Name,
status: 'success', status: 'success',
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`, message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
endpoint: target.canonical.EndpointId, endpointId: target.canonical.EndpointId,
redeployType: REDEPLOY_TYPES.MAINTENANCE redeployType: REDEPLOY_TYPES.MAINTENANCE,
metadata: {
removedDuplicates: results.filter((entry) => entry.status === 'deleted').map((entry) => entry.id)
}
}); });
res.json({ 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) => { app.get('/api/logs', (req, res) => {
const perPageParam = req.query.perPage ?? req.query.limit; const perPageParam = req.query.perPage ?? req.query.limit;
const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500); 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 limit = perPage === 'all' ? undefined : perPage;
const offset = perPage === 'all' ? 0 : (page - 1) * perPage; const offset = perPage === 'all' ? 0 : (page - 1) * perPage;
const { whereClause, params } = buildLogFilter(req.query); const { whereClause, params } = buildEventLogFilter(req.query);
const baseQuery = ` const baseQuery = `
SELECT SELECT
id, id,
timestamp, timestamp,
stack_id AS stackId, category,
stack_name AS stackName, event_type AS eventType,
action,
status, 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, message,
endpoint, metadata
redeploy_type AS redeployType FROM event_logs
FROM redeploy_logs
${whereClause} ${whereClause}
ORDER BY datetime(timestamp) DESC ORDER BY datetime(timestamp) DESC
`; `;
const countQuery = ` const countQuery = `
SELECT COUNT(*) as total SELECT COUNT(*) as total
FROM redeploy_logs FROM event_logs
${whereClause} ${whereClause}
`; `;
@@ -2326,7 +2644,25 @@ app.get('/api/logs', (req, res) => {
try { try {
const stmt = db.prepare(query); 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; const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
res.json({ res.json({
@@ -2336,11 +2672,8 @@ app.get('/api/logs', (req, res) => {
perPage: perPage === 'all' ? 'all' : limit perPage: perPage === 'all' ? 'all' : limit
}); });
} catch (err) { } catch (err) {
console.error('❌ Fehler beim Abrufen der Redeploy-Logs:', err.message); console.error('❌ Fehler beim Abrufen der Event-Logs:', err.message);
if (err.message.includes('no such table')) { res.status(500).json({ error: 'Fehler beim Abrufen der Logs' });
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' });
} }
}); });
@@ -2351,24 +2684,24 @@ app.delete('/api/logs/:id', (req, res) => {
} }
try { try {
const changes = deleteLogById(id); const changes = deleteEventLogById(id);
if (!changes) { if (!changes) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' }); return res.status(404).json({ error: 'Eintrag nicht gefunden' });
} }
res.json({ success: true, deleted: changes }); res.json({ success: true, deleted: changes });
} catch (err) { } catch (err) {
console.error('❌ Fehler beim Löschen des Redeploy-Logs:', err.message); console.error('❌ Fehler beim Löschen des Event-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Löschen des Redeploy-Logs' }); res.status(500).json({ error: 'Fehler beim Löschen des Logs' });
} }
}); });
app.delete('/api/logs', (req, res) => { app.delete('/api/logs', (req, res) => {
try { try {
const deleted = deleteLogsByFilters(req.query); const deleted = deleteEventLogsByFilters(req.query);
res.json({ success: true, deleted }); res.json({ success: true, deleted });
} catch (err) { } catch (err) {
console.error('❌ Fehler beim Löschen der Redeploy-Logs:', err.message); console.error('❌ Fehler beim Löschen der Event-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Löschen der Redeploy-Logs' }); res.status(500).json({ error: 'Fehler beim Löschen der Logs' });
} }
}); });
@@ -2379,13 +2712,13 @@ app.get('/api/logs/export', (req, res) => {
} }
try { try {
const payload = exportLogsByFilters(req.query, format); const payload = exportEventLogsByFilters(req.query, format);
res.setHeader('Content-Type', payload.contentType); res.setHeader('Content-Type', payload.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`);
res.send(payload.content); res.send(payload.content);
} catch (err) { } catch (err) {
console.error('❌ Fehler beim Export der Redeploy-Logs:', err.message); console.error('❌ Fehler beim Export der Event-Logs:', err.message);
res.status(500).json({ error: 'Fehler beim Export der Redeploy-Logs' }); 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) => { app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`); console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
let endpointId;
try { try {
const endpointId = requireActiveEndpointId(); endpointId = requireActiveEndpointId();
const stacksRes = await axiosInstance.get('/api/stacks'); const stacksRes = await axiosInstance.get('/api/stacks');
const filteredStacks = stacksRes.data.filter(stack => String(stack.EndpointId) === String(endpointId)); 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 stackSummaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks'; const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks';
logRedeployEvent({ logEvent({
stackId: '---', category: 'stack',
stackName: '---', eventType: REDEPLOY_TYPES.ALL,
status: 'started', action: 'redeploy-alle',
message: `Redeploy ALL gestartet für: ${stackSummary}`, status: 'gestartet',
endpoint: endpointId, entityType: 'bulk-operation',
redeployType: REDEPLOY_TYPES.ALL 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) { if (!eligibleStacks.length) {
@@ -2458,27 +2801,40 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
} }
} }
logRedeployEvent({ logEvent({
stackId: '---', category: 'stack',
stackName: '---', eventType: REDEPLOY_TYPES.ALL,
status: 'success', action: 'redeploy-alle',
message: 'Redeploy ALL abgeschlossen', status: 'erfolgreich',
endpoint: endpointId, entityType: 'bulk-operation',
redeployType: REDEPLOY_TYPES.ALL 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' }); res.json({ success: true, message: 'Redeploy ALL abgeschlossen' });
} catch (err) { } catch (err) {
const message = err.response?.data?.message || err.message; const message = err.response?.data?.message || err.message;
logRedeployEvent({ logEvent({
stackId: '---', category: 'stack',
stackName: '---', eventType: REDEPLOY_TYPES.ALL,
action: 'redeploy-alle',
status: 'error', 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, message,
endpoint: endpointId, source: 'system'
redeployType: REDEPLOY_TYPES.ALL
}); });
console.error('❌ Fehler bei Redeploy ALL:', message); console.error('❌ Fehler bei Redeploy ALLE:', message);
res.status(500).json({ error: 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' }); return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' });
} }
let endpointId;
try { try {
const endpointId = requireActiveEndpointId(); endpointId = requireActiveEndpointId();
const normalizedIds = stackIds.map((id) => String(id)); const normalizedIds = stackIds.map((id) => String(id));
const { filteredStacks } = await loadStackCollections(); const { filteredStacks } = await loadStackCollections();
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack])); 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 summaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
const summaryText = summaryList.length ? summaryList.join(', ') : 'keine Stacks'; const summaryText = summaryList.length ? summaryList.join(', ') : 'keine Stacks';
logRedeployEvent({ logEvent({
stackId: stackIds.join(','), category: 'stack',
stackName: `Auswahl (${stackIds.length})`, eventType: REDEPLOY_TYPES.SELECTION,
status: 'started', 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}`, message: `Redeploy Auswahl gestartet für: ${summaryText}`,
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.SELECTION requestedStackIds: normalizedIds
},
source: 'system'
}); });
if (!eligibleStacks.length) { if (!eligibleStacks.length) {
logRedeployEvent({ logEvent({
stackId: stackIds.join(','), category: 'stack',
stackName: `Auswahl (${stackIds.length})`, eventType: REDEPLOY_TYPES.SELECTION,
status: 'success', 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', message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.SELECTION requestedStackIds: normalizedIds,
skipped: true
},
source: 'system'
}); });
return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' }); 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({ logEvent({
stackId: stackIds.join(','), category: 'stack',
stackName: `Auswahl (${stackIds.length})`, eventType: REDEPLOY_TYPES.SELECTION,
status: 'success', 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', message: 'Redeploy Auswahl abgeschlossen',
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.SELECTION processedStackIds: eligibleStacks.map((stack) => String(stack.Id))
},
source: 'system'
}); });
res.json({ success: true, message: 'Redeploy Auswahl abgeschlossen' }); res.json({ success: true, message: 'Redeploy Auswahl abgeschlossen' });
} catch (err) { } catch (err) {
const message = err.response?.data?.message || err.message; const message = err.response?.data?.message || err.message;
logRedeployEvent({ const normalized = Array.isArray(stackIds) ? stackIds.map((id) => String(id)) : [];
stackId: Array.isArray(stackIds) ? stackIds.join(',') : String(stackIds ?? ''), logEvent({
stackName: `Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`, category: 'stack',
status: 'error', 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, message,
endpoint: endpointId, metadata: {
redeployType: REDEPLOY_TYPES.SELECTION requestedStackIds: normalized
},
source: 'system'
}); });
console.error('❌ Fehler bei Redeploy Auswahl:', message); console.error('❌ Fehler bei Redeploy Auswahl:', message);
res.status(500).json({ error: 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 { getJsonSetting, setJsonSetting } from '../db/settings.js';
import { logEvent } from '../logging/eventLogs.js';
const MAINTENANCE_KEY = 'maintenance_mode'; const MAINTENANCE_KEY = 'maintenance_mode';
@@ -44,6 +45,19 @@ export function isMaintenanceModeActive() {
} }
export function activateMaintenanceMode({ message = null, extra = null } = {}) { 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(); const now = new Date().toISOString();
return persistState({ return persistState({
active: true, active: true,
@@ -54,6 +68,19 @@ export function activateMaintenanceMode({ message = null, extra = null } = {}) {
} }
export function deactivateMaintenanceMode({ message = 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({ return persistState({
active: false, active: false,
message, 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 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> <script type="module" crossorigin src="/assets/index-8d2b4f50.js"></script>
<link rel="stylesheet" href="/assets/index-5f261614.css"> <link rel="stylesheet" href="/assets/index-11780ccd.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+365 -5
View File
@@ -1,9 +1,11 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { logEvent } from '../logging/eventLogs.js';
import { import {
hashPassword, hashPassword,
normalizeAvatarColor, normalizeAvatarColor,
pickRandomAvatarColor, pickRandomAvatarColor,
DEFAULT_AVATAR_COLOR DEFAULT_AVATAR_COLOR,
SUPERUSER_GROUP_NAME
} from '../auth/superuser.js'; } from '../auth/superuser.js';
const selectUsersWithGroups = db.prepare(` const selectUsersWithGroups = db.prepare(`
@@ -42,6 +44,12 @@ const selectUserWithGroupsById = db.prepare(`
GROUP BY u.id 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 selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?'); 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 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(` const insertUserStatement = db.prepare(`
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at) INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`); `);
const 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) => { const parseGroupPairs = (rawPairs) => {
if (!rawPairs) { if (!rawPairs) {
return []; return [];
@@ -119,6 +154,18 @@ const insertUserWithGroups = db.transaction(({ username, email, passwordHash, pa
return userId; 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) => { const normalizeEmail = (value) => {
if (!value) return null; if (!value) return null;
const trimmed = String(value).trim(); const trimmed = String(value).trim();
@@ -128,7 +175,8 @@ const normalizeEmail = (value) => {
return trimmed.toLowerCase(); 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() : ''; const normalizedUsername = typeof username === 'string' ? username.trim() : '';
if (!normalizedUsername) { if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED'); const error = new Error('USERNAME_REQUIRED');
@@ -150,6 +198,12 @@ export function createUser({ username, email, password, groupId, avatarColor })
throw error; 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); const normalizedEmail = normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) { if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL'); const error = new Error('INVALID_EMAIL');
@@ -200,10 +254,30 @@ export function createUser({ username, email, password, groupId, avatarColor })
groupId: numericGroupId 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); const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) { if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID'); const error = new Error('INVALID_USER_ID');
@@ -229,10 +303,16 @@ export function updateUserGroups(userId, groupIds) {
: []; : [];
const missingGroupIds = []; const missingGroupIds = [];
const groupDetails = [];
normalizedGroupIds.forEach((groupId) => { normalizedGroupIds.forEach((groupId) => {
const groupRow = selectGroupById.get(groupId); const groupRow = selectGroupById.get(groupId);
if (!groupRow) { if (!groupRow) {
missingGroupIds.push(groupId); missingGroupIds.push(groupId);
} else {
groupDetails.push({
id: groupId,
name: groupRow.name
});
} }
}); });
@@ -244,5 +324,285 @@ export function updateUserGroups(userId, groupIds) {
} }
applyUserGroupAssignments(numericUserId, normalizedGroupIds); 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"; } from "@/widgets/layout";
import routes from "@/routes"; import routes from "@/routes";
import { UserDetails } from "@/pages/dashboard/userDetails.jsx"; import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
import { useMaterialTailwindController, setOpenConfigurator } from "@/components"; import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
export function Dashboard() { export function Dashboard() {
@@ -156,6 +157,7 @@ export function Dashboard() {
)) ))
)} )}
<Route path="users/:userId" element={<UserDetails />} /> <Route path="users/:userId" element={<UserDetails />} />
<Route path="usergroups/:groupId" element={<UserGroupDetail />} />
</Routes> </Routes>
<div className="text-blue-gray-600"> <div className="text-blue-gray-600">
<Footer /> <Footer />
+4 -7
View File
@@ -90,27 +90,24 @@ export function SignIn() {
body: JSON.stringify({ identifier: trimmedIdentifier, password: trimmedPassword }), body: JSON.stringify({ identifier: trimmedIdentifier, password: trimmedPassword }),
}); });
if (response.status === 403) { const payload = await response.json().catch(() => ({}));
navigate("/setup", { replace: true });
return;
}
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => ({}));
if (response.status === 401 || payload.error === "INVALID_CREDENTIALS") { if (response.status === 401 || payload.error === "INVALID_CREDENTIALS") {
setError("Ungültige Zugangsdaten."); setError("Ungültige Zugangsdaten.");
} else if (payload.error === "MISSING_CREDENTIALS") { } else if (payload.error === "MISSING_CREDENTIALS") {
setError("Bitte fülle beide Felder aus."); 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 }); navigate("/setup", { replace: true });
return; return;
} else if (payload.error === "USER_INACTIVE") {
setError("Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.");
} else { } else {
setError("Anmeldung fehlgeschlagen. Bitte versuche es erneut."); setError("Anmeldung fehlgeschlagen. Bitte versuche es erneut.");
} }
return; return;
} }
await response.json().catch(() => ({}));
navigate("/dashboard/stacks", { replace: true }); navigate("/dashboard/stacks", { replace: true });
} catch (err) { } catch (err) {
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", 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/users";
export * from "@/pages/dashboard/usergroups"; export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/userDetails"; 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;
+368 -270
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 axios from "axios";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { import {
Card, Card,
CardHeader,
CardBody, CardBody,
Typography, Typography,
Button, Button,
Spinner, Spinner,
CardFooter,
Switch,
Tooltip,
Avatar,
Select, Select,
Option Option,
Input,
Switch
} from "@material-tailwind/react"; } from "@material-tailwind/react";
import {
PencilIcon,
} from "@heroicons/react/24/outline";
import { Link } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx"; import { useToast } from "@/components/ToastProvider.jsx";
import { ProfileInfoCard } from "@/widgets/cards"; import { platformSettingsData } from "@/data";
import { platformSettingsData, projectsData } from "@/data";
import { AVATAR_COLORS } from "@/data/avatarColors.js"; import { AVATAR_COLORS } from "@/data/avatarColors.js";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx"; import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const _ = AVATAR_COLORS.join(" "); const _ = AVATAR_COLORS.join(" ");
const normalizeUserGroups = (rawGroups) => { const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) { if (!Array.isArray(rawGroups)) {
return []; return [];
@@ -67,47 +58,74 @@ const mapUser = (item) => ({
groups: normalizeUserGroups(item?.groups) 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() { export function UserDetails() {
const { userId } = useParams(); const { userId } = useParams();
const { showToast } = useToast(); const { showToast } = useToast();
const { maintenance } = useMaintenance(); const { maintenance } = useMaintenance();
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
const initialFormValuesRef = useRef(buildInitialFormValues(null));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
const [availableGroups, setAvailableGroups] = useState([]); const [availableGroups, setAvailableGroups] = useState([]);
const [groupsLoading, setGroupsLoading] = useState(false); const [groupsLoading, setGroupsLoading] = useState(false);
const [groupsError, setGroupsError] = useState(""); const [groupsError, setGroupsError] = useState("");
const [selectedGroupId, setSelectedGroupId] = useState(null); const [savingUser, setSavingUser] = useState(false);
const [savingGroup, setSavingGroup] = useState(false); const [saveError, setSaveError] = useState("");
const maintenanceActive = Boolean(maintenance?.active); 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 numericUserId = useMemo(() => {
const asNumber = Number(userId); const asNumber = Number(userId);
return Number.isFinite(asNumber) ? asNumber : null; return Number.isFinite(asNumber) ? asNumber : null;
}, [userId]); }, [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 () => { const fetchUserDetails = useCallback(async () => {
if (!numericUserId) { if (!numericUserId) {
setError("Ungültige Benutzer-ID."); setError("Ungültige Benutzer-ID.");
setUser(null); setUser(null);
setFormValues(buildInitialFormValues(null));
initialFormValuesRef.current = buildInitialFormValues(null);
setHasLoaded(true); setHasLoaded(true);
return; return;
} }
@@ -122,7 +140,10 @@ export function UserDetails() {
throw new Error("USER_NOT_FOUND"); throw new Error("USER_NOT_FOUND");
} }
setUser(item); setUser(item);
setError(""); const initialValues = buildInitialFormValues(item);
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
} catch (err) { } catch (err) {
const serverError = err.response?.data?.error; const serverError = err.response?.data?.error;
let message = "Benutzerdetails konnten nicht geladen werden."; let message = "Benutzerdetails konnten nicht geladen werden.";
@@ -136,6 +157,8 @@ export function UserDetails() {
} }
setUser(null); setUser(null);
initialFormValuesRef.current = buildInitialFormValues(null);
setFormValues(buildInitialFormValues(null));
setError(message); setError(message);
showToast({ showToast({
variant: "error", variant: "error",
@@ -185,130 +208,245 @@ export function UserDetails() {
fetchAvailableGroups(); fetchAvailableGroups();
}, [fetchAvailableGroups]); }, [fetchAvailableGroups]);
const originalGroupId = useMemo(() => { const hasChanges = useMemo(() => {
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) { if (!hasLoaded || !user) {
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) {
return false; 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) => { const handleGroupChange = useCallback((value) => {
if (!value) { if (!value) {
setSelectedGroupId(null); setFormValues((prev) => ({
...prev,
groupId: null
}));
return; return;
} }
const numeric = Number(value); 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(() => { const handleAvatarColorChange = useCallback((value) => {
setSelectedGroupId(originalGroupId); setFormValues((prev) => ({
}, [originalGroupId]); ...prev,
avatarColor: value || ""
}));
}, []);
const handleSaveGroup = useCallback(async () => { const handleSaveUser = useCallback(async () => {
if (!user || !hasGroupChanges) { if (!user || !hasChanges) {
return; return;
} }
const payloadGroups = selectedGroupId ? [selectedGroupId] : []; setSavingUser(true);
setSaveError("");
setSavingGroup(true);
setGroupsError("");
try { 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); const updatedUser = mapUser(response.data?.item || response.data?.user);
setUser(updatedUser); setUser(updatedUser);
const nextGroupId = Array.isArray(updatedUser.groups) && updatedUser.groups.length > 0 const nextInitial = buildInitialFormValues(updatedUser);
? Number(updatedUser.groups[0].id) initialFormValuesRef.current = { ...nextInitial };
: null; setFormValues(nextInitial);
setSelectedGroupId(Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null); setGroupsError("");
showToast({ showToast({
variant: "success", variant: "success",
title: "Globale Rolle aktualisiert", title: "Benutzer gespeichert",
description: "Die Gruppenzuordnung wurde gespeichert." description: "Die Änderungen wurden erfolgreich gespeichert."
}); });
} catch (err) { } catch (err) {
const serverError = err.response?.data?.error; const serverError = err.response?.data?.error;
let message = err.message || "Die globale Rolle konnte nicht aktualisiert werden."; let message = "Die Benutzerdaten konnten nicht gespeichert werden.";
if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig."; if (serverError === "USERNAME_REQUIRED") {
} else if (serverError === "USER_NOT_FOUND") { message = "Bitte einen Benutzernamen angeben.";
message = "Der Benutzer konnte nicht gefunden werden."; } 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") { } else if (serverError === "GROUP_NOT_FOUND") {
message = "Die ausgewählte Gruppe existiert nicht mehr."; message = "Die ausgewählte Benutzergruppe existiert nicht mehr.";
} else if (typeof serverError === "string" && serverError.length > 0) {
message = serverError;
}
setGroupsError(message); setGroupsError(message);
}
setSaveError(message);
showToast({ showToast({
variant: "error", variant: "error",
title: "Globale Rolle", title: "Speichern fehlgeschlagen",
description: message description: message
}); });
} finally { } finally {
setSavingGroup(false); setSavingUser(false);
} }
}, [user, selectedGroupId, hasGroupChanges, showToast]); }, [user, hasChanges, formValues, showToast, isSuperuserUser]);
const selectedGroupLabel = useMemo(() => {
if (!selectedGroupId) {
return null;
}
const match = availableGroups.find((group) => group.id === selectedGroupId);
return match ? match.name : null;
}, [availableGroups, selectedGroupId]);
const selectValue = selectedGroupId ? String(selectedGroupId) : "";
const selectDisabled = maintenanceActive || savingGroup || !user || groupsLoading;
const avatarLabel = useMemo(() => { const avatarLabel = useMemo(() => {
const source = (user?.username || user?.email || "").trim(); const source = (formValues.username || formValues.email || "").trim();
if (!source) { if (!source) {
return "?"; return "?";
} }
return source.charAt(0).toUpperCase(); 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 ( 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="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 className="absolute inset-0 h-full w-full bg-gray-900/75" />
</div> </div>
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100"> <Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
<CardBody className="p-4"> <CardBody className="p-4">
<div className="mb-10 flex items-center justify-between flex-wrap gap-6"> <div className="mb-10 flex flex-wrap items-center justify-between gap-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div <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}`} 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={user?.username || "Benutzeravatar"} aria-label={formValues.username || "Benutzeravatar"}
> >
{avatarLabel} {avatarLabel}
</div> </div>
<div> <div>
<Typography variant="h5" color="blue-gray" className="mb-1"> <Typography variant="h5" color="blue-gray">
{user?.username} {formValues.username || ""}
</Typography> </Typography>
<Typography className="text-xs font-semibold uppercase tracking-wide text-stormGrey-400"> <Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
User-ID: {user?.id || ""} {formValues.email || ""}
</Typography> </Typography>
</div> </div>
</div> </div>
{hasChanges && (
<Button
color="green"
className="normal-case"
onClick={handleSaveUser}
disabled={maintenanceActive || savingUser}
>
{savingUser ? "Speichert ..." : "Änderungen speichern"}
</Button>
)}
</div> </div>
{loading && !user && ( {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"> <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">
@@ -321,7 +459,133 @@ export function UserDetails() {
{error} {error}
</div> </div>
)} )}
<div className="gird-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3"> {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>
)}
{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={groupSelectValue}
onChange={handleGroupChange}
disabled={selectDisabled}
variant="outlined"
>
{filteredGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
)}
</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={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> <div>
<Typography variant="h6" color="blue-gray" className="mb-3"> <Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings Platform Settings
@@ -349,176 +613,10 @@ export function UserDetails() {
))} ))}
</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> </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">
<Select
label="Benutzergruppe wählen"
value={selectValue}
onChange={handleGroupChange}
disabled={selectDisabled}
variant="outlined"
>
{availableGroups.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."}
</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"
>
{title}
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{description}
</Typography>
</CardBody> </CardBody>
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1"> </Card></div>
<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>
</> </>
); );
} }
@@ -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 React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios"; import axios from "axios";
import { useNavigate } from "react-router-dom";
import { import {
Card, Card,
CardHeader, CardHeader,
@@ -24,10 +25,12 @@ export function Usergroups() {
const [newGroupDescription, setNewGroupDescription] = useState(""); const [newGroupDescription, setNewGroupDescription] = useState("");
const [createGroupError, setCreateGroupError] = useState(""); const [createGroupError, setCreateGroupError] = useState("");
const [creatingGroup, setCreatingGroup] = useState(false); const [creatingGroup, setCreatingGroup] = useState(false);
const [deletingGroupId, setDeletingGroupId] = useState(null);
const { showToast } = useToast(); const { showToast } = useToast();
const { maintenance } = useMaintenance(); const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active); const maintenanceActive = Boolean(maintenance?.active);
const navigate = useNavigate();
const { const {
page, page,
@@ -54,6 +57,7 @@ export function Usergroups() {
id: item.id, id: item.id,
name: item.name || "", name: item.name || "",
description: item.description || "", description: item.description || "",
avatarColor: item.avatarColor || null,
memberCount: Number(item.memberCount) || 0, memberCount: Number(item.memberCount) || 0,
members: Array.isArray(item.members) members: Array.isArray(item.members)
? item.members.map((member) => ({ ? item.members.map((member) => ({
@@ -233,8 +237,86 @@ export function Usergroups() {
const createGroupDisabled = maintenanceActive || creatingGroup; 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 ( 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"> <Card className="border border-blue-gray-100 shadow-sm">
<CardHeader variant="gradient" color="gray" className="mb-5 p-4"> <CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography <Typography
@@ -245,13 +327,6 @@ export function Usergroups() {
</Typography> </Typography>
</CardHeader> </CardHeader>
<CardBody className="pt-0"> <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"> <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"> <Typography variant="h6" color="blue-gray" className="mb-2">
Neue Benutzergruppe anlegen 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">Gruppenname</th>
<th className="px-6 py-4 font-semibold">Beschreibung</th> <th className="px-6 py-4 font-semibold">Beschreibung</th>
<th className="px-6 py-4 font-semibold">Mitglieder</th> <th className="px-6 py-4 font-semibold">Mitglieder</th>
<th className="px-6 py-4 font-semibold">Anzahl</th>
<th className="px-6 py-4 font-semibold">Erstellt am</th> <th className="px-6 py-4 font-semibold">Erstellt am</th>
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th> <th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
<th className="px-6 py-4 font-semibold">Aktionen</th> <th className="px-6 py-4 font-semibold">Aktionen</th>
@@ -358,6 +432,7 @@ export function Usergroups() {
) : ( ) : (
paginatedGroups.map((group, index) => { paginatedGroups.map((group, index) => {
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50"; const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
return ( return (
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}> <tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4"> <td className="px-6 py-4">
@@ -375,29 +450,10 @@ export function Usergroups() {
</Typography> </Typography>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{group.members.length > 0 ? ( <Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
<div className="flex flex-wrap gap-2"> {group.memberCount.toLocaleString("de-DE")}
{group.members.map((member) => ( </Typography>
<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"
/>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600"> <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> </Typography>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
Aktionen <Button
</Typography> 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> </td>
</tr> </tr>
); );
+179 -106
View File
@@ -15,7 +15,6 @@ import { useNavigate } from "react-router-dom";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx"; import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx"; import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx"; import { useToast } from "@/components/ToastProvider.jsx";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
const normalizeUserGroups = (rawGroups) => { const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) { if (!Array.isArray(rawGroups)) {
@@ -55,9 +54,10 @@ export function Users() {
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [newGroupId, setNewGroupId] = useState(""); const [newGroupId, setNewGroupId] = useState("");
const [newAvatarColor, setNewAvatarColor] = useState("");
const [createError, setCreateError] = useState(""); const [createError, setCreateError] = useState("");
const [creatingUser, setCreatingUser] = useState(false); const [creatingUser, setCreatingUser] = useState(false);
const [deletingUserId, setDeletingUserId] = useState(null);
const [togglingUserId, setTogglingUserId] = useState(null);
const { showToast } = useToast(); const { showToast } = useToast();
const { maintenance } = useMaintenance(); const { maintenance } = useMaintenance();
@@ -123,6 +123,7 @@ export function Users() {
name: item.name || "" name: item.name || ""
})) }))
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.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")); .sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
setAvailableGroups(normalized); setAvailableGroups(normalized);
} catch (err) { } catch (err) {
@@ -243,10 +244,140 @@ export function Users() {
setNewEmail(""); setNewEmail("");
setNewPassword(""); setNewPassword("");
setNewGroupId(""); setNewGroupId("");
setNewAvatarColor("");
setCreateError(""); 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 () => { const handleCreateUser = useCallback(async () => {
if (maintenanceActive) { if (maintenanceActive) {
return; return;
@@ -277,9 +408,6 @@ export function Users() {
if (trimmedEmail) { if (trimmedEmail) {
payload.email = trimmedEmail; payload.email = trimmedEmail;
} }
if (newAvatarColor) {
payload.avatarColor = newAvatarColor;
}
const response = await axios.post("/api/users", payload); const response = await axios.post("/api/users", payload);
resetNewUserForm(); resetNewUserForm();
fetchUsers(); fetchUsers();
@@ -330,16 +458,14 @@ export function Users() {
newEmail, newEmail,
newPassword, newPassword,
newGroupId, newGroupId,
newAvatarColor,
showToast, showToast,
resetNewUserForm, resetNewUserForm,
fetchUsers fetchUsers
]); ]);
const createDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0; const hasSelectableGroups = availableGroups.length > 0;
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0; const createDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const avatarSelectDisabled = maintenanceActive || creatingUser; const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const renderSelectedGroup = useCallback( const renderSelectedGroup = useCallback(
(element) => { (element) => {
if (element && element.props && element.props.children) { if (element && element.props && element.props.children) {
@@ -354,26 +480,13 @@ export function Users() {
[availableGroups, newGroupId] [availableGroups, newGroupId]
); );
const renderSelectedAvatar = useCallback(
(element) => {
if (element && element.props && element.props.children) {
return element.props.children;
}
if (!newAvatarColor) {
return "Automatisch (zufällig)";
}
return ( return (
<span className="flex items-center gap-2"> <div className="mt-12 mb-8 flex flex-col gap-12">
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${newAvatarColor}`} /> {maintenanceActive && (
<span className="text-xs">{newAvatarColor}</span> <div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
</span> Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
); </div>
}, )}
[newAvatarColor]
);
return (
<div className="mt-12">
<Card> <Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4"> <CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography <Typography
@@ -384,16 +497,14 @@ export function Users() {
</Typography> </Typography>
</CardHeader> </CardHeader>
<CardBody className="pt-0"> <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"> <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"> <Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Benutzer anlegen Neuen Benutzer anlegen
</Typography> </Typography>
{hasSelectableGroups ? (
<>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4"> <Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder. Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
</Typography> </Typography>
@@ -445,30 +556,10 @@ export function Users() {
</Option> </Option>
))} ))}
</Select> </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>
<div className="mt-4 text-sm text-stormGrey-500"> <div className="mt-4 text-sm text-stormGrey-500">
{groupsLoading {groupsLoading
? "Benutzergruppen werden geladen ..." ? "Benutzergruppen werden geladen ..."
: availableGroups.length === 0
? "Noch keine Benutzergruppen vorhanden. Bitte legen Sie zuerst eine globale Rolle an."
: ""} : ""}
</div> </div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -479,58 +570,15 @@ export function Users() {
Formular zurücksetzen Formular zurücksetzen
</Button> </Button>
</div> </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>
<div className="mb-8"> <div className="mb-8">
<div className="flex flex-wrap gap-2"> {/* <div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
<div>
<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="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
<div className="md:flex-1"> <div className="md:flex-1">
<Input <Input
@@ -557,7 +605,6 @@ export function Users() {
</div> </div>
</div> </div>
</div> </div>
</div>
{error && ( {error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"> <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error} {error}
@@ -594,6 +641,9 @@ export function Users() {
) : ( ) : (
paginatedUsers.map((user, index) => { paginatedUsers.map((user, index) => {
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50"; const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
const isSuperuserUser = Array.isArray(user?.groups)
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
: false;
return ( return (
<tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}> <tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4"> <td className="px-6 py-4">
@@ -624,12 +674,20 @@ export function Users() {
)} )}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<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 <Chip
value={user.isActive ? "Aktiv" : "Deaktiviert"} value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm" size="sm"
color={user.isActive ? "green" : "red"} color={user.isActive ? "green" : "red"}
variant="ghost" variant="ghost"
className="pointer-events-none"
/> />
</button>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600"> <Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
@@ -642,6 +700,7 @@ export function Users() {
</Typography> </Typography>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
<Button <Button
size="sm" size="sm"
variant="outlined" variant="outlined"
@@ -650,6 +709,20 @@ export function Users() {
> >
Details Details
</Button> </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> </td>
</tr> </tr>
); );