User, Groups, Logs Update
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+129
-19
@@ -2,16 +2,27 @@ import { db } from './index.js';
|
||||
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
const createRedeployLogsTable = `
|
||||
CREATE TABLE IF NOT EXISTS redeploy_logs (
|
||||
const createEventLogsTable = `
|
||||
CREATE TABLE IF NOT EXISTS event_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
stack_id TEXT NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
event_type TEXT,
|
||||
action TEXT,
|
||||
status TEXT,
|
||||
severity TEXT,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
entity_name TEXT,
|
||||
actor_type TEXT,
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
source TEXT,
|
||||
context_type TEXT,
|
||||
context_id TEXT,
|
||||
context_label TEXT,
|
||||
message TEXT,
|
||||
endpoint INTEGER,
|
||||
redeploy_type TEXT
|
||||
metadata TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -43,6 +54,7 @@ CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
avatar_color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -172,20 +184,112 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
||||
);
|
||||
`;
|
||||
|
||||
db.exec(createRedeployLogsTable);
|
||||
const tableExists = (tableName) => {
|
||||
return Boolean(
|
||||
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(tableName)
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all();
|
||||
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type');
|
||||
if (!hasRedeployType) {
|
||||
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
|
||||
console.log('ℹ️ redeploy_type column hinzugefügt');
|
||||
const serializeMetadata = (payload) => {
|
||||
if (!payload || typeof payload !== 'object' || !Object.keys(payload).length) {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch (err) {
|
||||
console.error('⚠️ Konnte Migrations-Metadaten nicht serialisieren:', err.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✅ redeploy_logs table ready');
|
||||
db.exec(createEventLogsTable);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_timestamp ON event_logs (timestamp DESC);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_category ON event_logs (category);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_event_type ON event_logs (event_type);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_entity ON event_logs (entity_type, entity_id);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_event_logs_context ON event_logs (context_type, context_id);');
|
||||
console.log('✅ event_logs table ready');
|
||||
|
||||
if (tableExists('redeploy_logs')) {
|
||||
try {
|
||||
const legacyRows = db.prepare(`
|
||||
SELECT id, timestamp, stack_id, stack_name, status, message, endpoint, redeploy_type
|
||||
FROM redeploy_logs
|
||||
ORDER BY id ASC
|
||||
`).all();
|
||||
|
||||
if (legacyRows.length) {
|
||||
const insertEventLog = db.prepare(`
|
||||
INSERT INTO event_logs (
|
||||
id,
|
||||
timestamp,
|
||||
category,
|
||||
event_type,
|
||||
action,
|
||||
status,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
context_type,
|
||||
context_id,
|
||||
message,
|
||||
metadata
|
||||
) VALUES (
|
||||
@id,
|
||||
@timestamp,
|
||||
'stack',
|
||||
@eventType,
|
||||
'redeploy',
|
||||
@status,
|
||||
'stack',
|
||||
@entityId,
|
||||
@entityName,
|
||||
@contextType,
|
||||
@contextId,
|
||||
@message,
|
||||
@metadata
|
||||
)
|
||||
`);
|
||||
|
||||
const migrate = db.transaction((rows) => {
|
||||
rows.forEach((row) => {
|
||||
const contextType = row.endpoint !== null && row.endpoint !== undefined ? 'endpoint' : null;
|
||||
const contextId = contextType ? String(row.endpoint) : null;
|
||||
const metadataPayload = {
|
||||
origin: 'redeploy_logs'
|
||||
};
|
||||
if (row.redeploy_type) {
|
||||
metadataPayload.redeployType = row.redeploy_type;
|
||||
}
|
||||
if (contextId) {
|
||||
metadataPayload.endpointId = contextId;
|
||||
}
|
||||
insertEventLog.run({
|
||||
id: row.id,
|
||||
timestamp: row.timestamp,
|
||||
eventType: row.redeploy_type ?? null,
|
||||
status: row.status ?? null,
|
||||
entityId: row.stack_id !== undefined && row.stack_id !== null ? String(row.stack_id) : null,
|
||||
entityName: row.stack_name ?? null,
|
||||
contextType,
|
||||
contextId,
|
||||
message: row.message ?? null,
|
||||
metadata: serializeMetadata(metadataPayload)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
migrate(legacyRows);
|
||||
console.log(`ℹ️ ${legacyRows.length} Einträge aus redeploy_logs migriert`);
|
||||
}
|
||||
|
||||
db.exec('DROP TABLE redeploy_logs;');
|
||||
console.log('ℹ️ Alte Tabelle redeploy_logs entfernt');
|
||||
} catch (err) {
|
||||
console.error('⚠️ Migration von redeploy_logs fehlgeschlagen:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
db.exec(createSettingsTable);
|
||||
console.log('✅ settings table ready');
|
||||
@@ -198,7 +302,13 @@ try {
|
||||
const hasAvatarColor = userColumns.some((column) => column.name === 'avatar_color');
|
||||
if (!hasAvatarColor) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT');
|
||||
console.log('ℹ️ avatar_color column hinzugefügt');
|
||||
console.log('ℹ️ avatar_color column in Tabelle users hinzugefügt');
|
||||
}
|
||||
const userGroupColumns = db.prepare('PRAGMA table_info(user_groups)').all();
|
||||
const hasAvatarColorGroup = userGroupColumns.some((column) => column.name === 'avatar_color');
|
||||
if (!hasAvatarColorGroup) {
|
||||
db.exec('ALTER TABLE user_groups ADD COLUMN avatar_color TEXT');
|
||||
console.log('ℹ️ avatar_color column in Tabelle user_groups hinzugefügt');
|
||||
}
|
||||
const emailColumn = userColumns.find((column) => column.name === 'email');
|
||||
if (emailColumn && emailColumn.notnull === 1) {
|
||||
|
||||
@@ -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
@@ -1,10 +1,18 @@
|
||||
import { db } from '../db/index.js';
|
||||
import {
|
||||
normalizeAvatarColor,
|
||||
DEFAULT_AVATAR_COLOR,
|
||||
SUPERUSER_GROUP_NAME,
|
||||
pickRandomAvatarColor
|
||||
} from '../auth/superuser.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
|
||||
const selectGroupsWithMembers = db.prepare(`
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g.avatar_color,
|
||||
g.created_at,
|
||||
g.updated_at,
|
||||
COUNT(DISTINCT u.id) AS member_count,
|
||||
@@ -21,6 +29,7 @@ const selectGroupWithMembersById = db.prepare(`
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g.avatar_color,
|
||||
g.created_at,
|
||||
g.updated_at,
|
||||
COUNT(DISTINCT u.id) AS member_count,
|
||||
@@ -32,6 +41,12 @@ const selectGroupWithMembersById = db.prepare(`
|
||||
GROUP BY g.id
|
||||
`);
|
||||
|
||||
const selectGroupByIdBasic = db.prepare(`
|
||||
SELECT id, name, description, avatar_color
|
||||
FROM user_groups
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectGroupIdByName = db.prepare(`
|
||||
SELECT id
|
||||
FROM user_groups
|
||||
@@ -40,8 +55,28 @@ const selectGroupIdByName = db.prepare(`
|
||||
`);
|
||||
|
||||
const insertGroupStatement = db.prepare(`
|
||||
INSERT INTO user_groups (name, description, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateGroupStatement = db.prepare(`
|
||||
UPDATE user_groups
|
||||
SET name = ?,
|
||||
description = ?,
|
||||
avatar_color = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const countGroupMembersStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS member_count
|
||||
FROM user_group_memberships
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const deleteGroupStatement = db.prepare(`
|
||||
DELETE FROM user_groups
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const parseMembers = (rawPairs) => {
|
||||
@@ -66,6 +101,7 @@ const sanitizeGroup = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
avatarColor: row.avatar_color || null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null,
|
||||
memberCount: Number(row.member_count) || 0,
|
||||
@@ -77,6 +113,15 @@ export function listGroups() {
|
||||
return rows.map(sanitizeGroup);
|
||||
}
|
||||
|
||||
export function getGroupById(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return null;
|
||||
}
|
||||
const row = selectGroupWithMembersById.get(numericId);
|
||||
return row ? sanitizeGroup(row) : null;
|
||||
}
|
||||
|
||||
export function createGroup({ name, description }) {
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!normalizedName) {
|
||||
@@ -93,8 +138,205 @@ export function createGroup({ name, description }) {
|
||||
}
|
||||
|
||||
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
|
||||
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null);
|
||||
const avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
|
||||
const groupId = Number(insertResult.lastInsertRowid);
|
||||
const row = selectGroupWithMembersById.get(groupId);
|
||||
return row ? sanitizeGroup(row) : null;
|
||||
const group = row ? sanitizeGroup(row) : null;
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-angelegt',
|
||||
action: 'anlegen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(groupId),
|
||||
entityName: group?.name ?? normalizedName,
|
||||
message: `Benutzergruppe "${normalizedName}" angelegt`,
|
||||
metadata: {
|
||||
description: group?.description ?? normalizedDescription ?? null,
|
||||
avatarColor
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||
if (!existingRow) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
|
||||
if (!normalizedName) {
|
||||
const error = new Error('GROUP_NAME_REQUIRED');
|
||||
error.code = 'GROUP_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nameRow = selectGroupIdByName.get(normalizedName);
|
||||
if (nameRow && Number(nameRow.id) !== numericId) {
|
||||
const error = new Error('GROUP_NAME_TAKEN');
|
||||
error.code = 'GROUP_NAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let normalizedDescription;
|
||||
if (description === undefined) {
|
||||
normalizedDescription = existingRow.description ?? null;
|
||||
} else if (description === null) {
|
||||
normalizedDescription = null;
|
||||
} else {
|
||||
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
|
||||
normalizedDescription = trimmedDescription ? trimmedDescription : null;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
|
||||
if (isSuperuserGroup) {
|
||||
if (name !== undefined) {
|
||||
const incomingName = typeof name === 'string' ? name.trim() : '';
|
||||
if (incomingName && incomingName !== existingRow.name) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
|
||||
? ''
|
||||
: String(existingRow.description).trim();
|
||||
const incomingDescriptionComparable = description === null
|
||||
? ''
|
||||
: typeof description === 'string'
|
||||
? description.trim()
|
||||
: '';
|
||||
|
||||
if (incomingDescriptionComparable !== existingDescriptionComparable) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce original values for protected fields
|
||||
normalizedDescription = existingRow.description ?? null;
|
||||
}
|
||||
|
||||
let colorToPersist = existingRow.avatar_color || null;
|
||||
if (avatarColor !== undefined) {
|
||||
const candidate = String(avatarColor || '').trim();
|
||||
if (!candidate) {
|
||||
colorToPersist = DEFAULT_AVATAR_COLOR;
|
||||
} else {
|
||||
const normalized = normalizeAvatarColor(candidate);
|
||||
if (!normalized) {
|
||||
const error = new Error('INVALID_AVATAR_COLOR');
|
||||
error.code = 'INVALID_AVATAR_COLOR';
|
||||
throw error;
|
||||
}
|
||||
colorToPersist = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
updateGroupStatement.run(
|
||||
isSuperuserGroup ? existingRow.name : normalizedName,
|
||||
normalizedDescription,
|
||||
colorToPersist,
|
||||
numericId
|
||||
);
|
||||
|
||||
const updatedGroup = getGroupById(numericId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-aktualisiert',
|
||||
action: 'aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(numericId),
|
||||
entityName: updatedGroup?.name ?? normalizedName,
|
||||
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
|
||||
metadata: {
|
||||
previous: {
|
||||
name: existingRow.name,
|
||||
description: existingRow.description ?? null,
|
||||
avatarColor: existingRow.avatar_color ?? null
|
||||
},
|
||||
current: updatedGroup
|
||||
? {
|
||||
name: updatedGroup.name,
|
||||
description: updatedGroup.description ?? null,
|
||||
avatarColor: updatedGroup.avatarColor ?? null,
|
||||
memberCount: updatedGroup.memberCount ?? 0
|
||||
}
|
||||
: {
|
||||
name: normalizedName,
|
||||
description: normalizedDescription ?? null,
|
||||
avatarColor: colorToPersist
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
export function deleteGroup(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||
if (!existingRow) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
if (isSuperuserGroup) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
|
||||
if (Number(memberCount) > 0) {
|
||||
const error = new Error('GROUP_HAS_MEMBERS');
|
||||
error.code = 'GROUP_HAS_MEMBERS';
|
||||
error.memberCount = Number(memberCount);
|
||||
throw error;
|
||||
}
|
||||
|
||||
deleteGroupStatement.run(numericId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-gelöscht',
|
||||
action: 'löschen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(numericId),
|
||||
entityName: existingRow.name ?? `ID ${numericId}`,
|
||||
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
|
||||
metadata: {
|
||||
description: existingRow.description ?? null,
|
||||
avatarColor: existingRow.avatar_color ?? null,
|
||||
memberCountBeforeDelete: Number(memberCount)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+521
-129
@@ -23,12 +23,12 @@ import {
|
||||
verifyPassword
|
||||
} from './auth/superuser.js';
|
||||
import {
|
||||
logRedeployEvent,
|
||||
buildLogFilter,
|
||||
deleteLogById,
|
||||
deleteLogsByFilters,
|
||||
exportLogsByFilters
|
||||
} from './db/redeployLogs.js';
|
||||
logEvent,
|
||||
buildEventLogFilter,
|
||||
deleteEventLogById,
|
||||
deleteEventLogsByFilters,
|
||||
exportEventLogsByFilters
|
||||
} from './logging/eventLogs.js';
|
||||
import { getSetting, setSetting, deleteSetting } from './db/settings.js';
|
||||
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
|
||||
import {
|
||||
@@ -45,8 +45,8 @@ import {
|
||||
removeServer,
|
||||
setServerApiKey
|
||||
} from './setup/index.js';
|
||||
import { listUsers, getUserById, updateUserGroups, createUser } from './users/index.js';
|
||||
import { listGroups, createGroup } from './groups/index.js';
|
||||
import { listUsers, getUserById, updateUserGroups, createUser, updateUserDetails, deleteUser, updateUserActiveStatus } from './users/index.js';
|
||||
import { listGroups, createGroup, getGroupById, updateGroupDetails, deleteGroup } from './groups/index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -301,6 +301,53 @@ const REDEPLOY_TYPES = {
|
||||
MAINTENANCE: 'Wartung'
|
||||
};
|
||||
|
||||
const logStackEvent = ({
|
||||
stackId,
|
||||
stackName,
|
||||
status,
|
||||
message,
|
||||
redeployType = null,
|
||||
endpointId = null,
|
||||
metadata = {}
|
||||
}) => {
|
||||
const metadataPayload = {
|
||||
...metadata
|
||||
};
|
||||
|
||||
if (redeployType) {
|
||||
metadataPayload.redeployType = redeployType;
|
||||
}
|
||||
|
||||
if (endpointId !== undefined && endpointId !== null) {
|
||||
metadataPayload.endpointId = String(endpointId);
|
||||
}
|
||||
|
||||
const hasMetadata = Object.keys(metadataPayload).length > 0;
|
||||
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: redeployType ?? null,
|
||||
action: 'redeploy',
|
||||
status: status ?? null,
|
||||
entityType: 'stack',
|
||||
entityId: stackId !== undefined && stackId !== null ? String(stackId) : null,
|
||||
entityName: stackName ?? null,
|
||||
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
|
||||
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
|
||||
message: message ?? null,
|
||||
metadata: hasMetadata ? metadataPayload : null
|
||||
});
|
||||
};
|
||||
|
||||
const parseJsonColumn = (value) => {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null;
|
||||
const PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script';
|
||||
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
|
||||
@@ -942,22 +989,40 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
});
|
||||
maintenanceActivated = true;
|
||||
|
||||
logRedeployEvent({
|
||||
stackId: 'portainer',
|
||||
stackName: 'Portainer',
|
||||
status: 'started',
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'portainer-update',
|
||||
action: 'aktualisieren',
|
||||
status: 'gestartet',
|
||||
entityType: 'service',
|
||||
entityId: 'portainer',
|
||||
entityName: 'Portainer',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
metadata: {
|
||||
targetVersion: targetVersion ?? null,
|
||||
scriptSource
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
logRedeployEvent({
|
||||
stackId: 'maintenance',
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'started',
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'Wartungsmodus',
|
||||
action: 'aktivieren',
|
||||
status: 'gestartet',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: 'Wartungsmodus aktiviert (Portainer Update)',
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
metadata: {
|
||||
reason: 'portainer-update',
|
||||
scriptSource
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
updatePortainerState({
|
||||
@@ -981,13 +1046,21 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
const statusAfter = await fetchPortainerStatusSummary().catch(() => null);
|
||||
const finalVersion = statusAfter?.currentVersion ?? null;
|
||||
|
||||
logRedeployEvent({
|
||||
stackId: 'portainer',
|
||||
stackName: 'Portainer',
|
||||
status: 'success',
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'portainer-update',
|
||||
action: 'aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'service',
|
||||
entityId: 'portainer',
|
||||
entityName: 'Portainer',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
metadata: {
|
||||
resultVersion: finalVersion ?? null
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
updatePortainerState({
|
||||
@@ -1005,24 +1078,37 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
if (maintenanceActivated || maintenanceState?.active) {
|
||||
deactivateMaintenanceMode({ message: 'Portainer Update abgeschlossen' });
|
||||
maintenanceActivated = false;
|
||||
logRedeployEvent({
|
||||
stackId: 'maintenance',
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'success',
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'Wartungsmodus',
|
||||
action: 'deaktivieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: 'Wartungsmodus deaktiviert',
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
metadata: {
|
||||
reason: 'portainer-update'
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err?.message || 'Portainer Update fehlgeschlagen';
|
||||
logRedeployEvent({
|
||||
stackId: 'portainer',
|
||||
stackName: 'Portainer',
|
||||
status: 'error',
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'portainer-update',
|
||||
action: 'aktualisieren',
|
||||
status: 'fehler',
|
||||
entityType: 'service',
|
||||
entityId: 'portainer',
|
||||
entityName: 'Portainer',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
updatePortainerState({
|
||||
@@ -1038,13 +1124,21 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
|
||||
if (maintenanceActivated || isMaintenanceModeActive()) {
|
||||
deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' });
|
||||
logRedeployEvent({
|
||||
stackId: 'maintenance',
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'error',
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'Wartungsmodus',
|
||||
action: 'deaktivieren',
|
||||
status: 'fehler',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: 'Wartungsmodus deaktiviert (Fehler)',
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
metadata: {
|
||||
reason: 'portainer-update'
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -1191,12 +1285,12 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
phase: REDEPLOY_PHASES.STARTED
|
||||
});
|
||||
|
||||
logRedeployEvent({
|
||||
logStackEvent({
|
||||
stackId: targetId,
|
||||
stackName: targetName,
|
||||
status: 'started',
|
||||
message: messages.started,
|
||||
endpoint: stack.EndpointId,
|
||||
endpointId: stack.EndpointId,
|
||||
redeployType
|
||||
});
|
||||
|
||||
@@ -1271,12 +1365,12 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
phase: REDEPLOY_PHASES.SUCCESS
|
||||
});
|
||||
|
||||
logRedeployEvent({
|
||||
logStackEvent({
|
||||
stackId: targetId,
|
||||
stackName: targetName,
|
||||
status: 'success',
|
||||
message: messages.success,
|
||||
endpoint: stack.EndpointId,
|
||||
endpointId: stack.EndpointId,
|
||||
redeployType
|
||||
});
|
||||
|
||||
@@ -1293,12 +1387,12 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
message: errorMessage
|
||||
});
|
||||
|
||||
logRedeployEvent({
|
||||
logStackEvent({
|
||||
stackId: fallbackId,
|
||||
stackName: fallbackName,
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
endpoint: stack?.EndpointId || endpointId,
|
||||
endpointId: stack?.EndpointId || endpointId,
|
||||
redeployType
|
||||
});
|
||||
|
||||
@@ -1621,10 +1715,14 @@ app.post('/api/auth/login', (req, res) => {
|
||||
}
|
||||
|
||||
const user = findUserByIdentifier(identifier);
|
||||
if (!user || !user.is_active) {
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({ error: 'USER_INACTIVE' });
|
||||
}
|
||||
|
||||
const passwordValid = verifyPassword(password, user.password_hash, user.password_salt);
|
||||
if (!passwordValid) {
|
||||
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
@@ -1731,6 +1829,63 @@ app.get('/api/users/:userId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:userId', (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
|
||||
const { username, email, password, avatarColor, groupId, groupIds } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const updatedUser = updateUserDetails(numericId, {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
avatarColor,
|
||||
groupId,
|
||||
groupIds
|
||||
});
|
||||
res.json({ item: updatedUser });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_USER_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
if (error.code === 'USERNAME_REQUIRED') {
|
||||
return res.status(400).json({ error: 'USERNAME_REQUIRED' });
|
||||
}
|
||||
if (error.code === 'INVALID_EMAIL') {
|
||||
return res.status(400).json({ error: 'INVALID_EMAIL' });
|
||||
}
|
||||
if (error.code === 'USERNAME_TAKEN') {
|
||||
return res.status(409).json({ error: 'USERNAME_TAKEN' });
|
||||
}
|
||||
if (error.code === 'EMAIL_TAKEN') {
|
||||
return res.status(409).json({ error: 'EMAIL_TAKEN' });
|
||||
}
|
||||
if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') {
|
||||
return res.status(400).json({ error: error.code });
|
||||
}
|
||||
if (error.code === 'INVALID_AVATAR_COLOR') {
|
||||
return res.status(400).json({ error: 'INVALID_AVATAR_COLOR' });
|
||||
}
|
||||
if (error.code === 'GROUP_NOT_FOUND') {
|
||||
return res.status(400).json({ error: 'GROUP_NOT_FOUND', details: error.missingGroupIds || [] });
|
||||
}
|
||||
if (error.code === 'GROUP_SUPERUSER_PROTECTED') {
|
||||
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
|
||||
}
|
||||
|
||||
console.error(`⚠️ [Users] Aktualisierung der Benutzerdaten fehlgeschlagen (${userId}):`, error);
|
||||
res.status(500).json({ error: 'USER_UPDATE_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:userId/groups', (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
@@ -1758,6 +1913,61 @@ app.put('/api/users/:userId/groups', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/users/:userId', (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
deleteUser(numericId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_USER_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
if (error.code === 'USER_SUPERUSER_PROTECTED') {
|
||||
return res.status(403).json({ error: 'USER_SUPERUSER_PROTECTED' });
|
||||
}
|
||||
|
||||
console.error(`⚠️ [Users] Löschen fehlgeschlagen (${userId}):`, error);
|
||||
res.status(500).json({ error: 'USER_DELETE_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:userId/active', (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
const { isActive } = req.body ?? {};
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = updateUserActiveStatus(numericId, Boolean(isActive));
|
||||
res.json({ item: updatedUser });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_USER_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
if (error.code === 'USER_SUPERUSER_PROTECTED') {
|
||||
return res.status(403).json({ error: 'USER_SUPERUSER_PROTECTED' });
|
||||
}
|
||||
|
||||
console.error(`⚠️ [Users] Aktualisierung des Aktivstatus fehlgeschlagen (${userId}):`, error);
|
||||
res.status(500).json({ error: 'USER_STATUS_UPDATE_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/groups', (req, res) => {
|
||||
try {
|
||||
const groups = listGroups();
|
||||
@@ -1768,6 +1978,26 @@ app.get('/api/groups', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/groups/:groupId', (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const group = getGroupById(numericId);
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||
}
|
||||
res.json({ item: group });
|
||||
} catch (error) {
|
||||
console.error(`⚠️ [Groups] Abruf der Gruppendetails fehlgeschlagen (${groupId}):`, error);
|
||||
res.status(500).json({ error: 'GROUP_FETCH_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/groups', (req, res) => {
|
||||
const { name, description } = req.body ?? {};
|
||||
try {
|
||||
@@ -1785,6 +2015,74 @@ app.post('/api/groups', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/groups/:groupId', (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
|
||||
const { name, description, avatarColor } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const updatedGroup = updateGroupDetails(numericId, { name, description, avatarColor });
|
||||
res.json({ item: updatedGroup });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_GROUP_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
if (error.code === 'GROUP_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||
}
|
||||
if (error.code === 'GROUP_NAME_REQUIRED') {
|
||||
return res.status(400).json({ error: 'GROUP_NAME_REQUIRED' });
|
||||
}
|
||||
if (error.code === 'GROUP_NAME_TAKEN') {
|
||||
return res.status(409).json({ error: 'GROUP_NAME_TAKEN' });
|
||||
}
|
||||
if (error.code === 'INVALID_AVATAR_COLOR') {
|
||||
return res.status(400).json({ error: 'INVALID_AVATAR_COLOR' });
|
||||
}
|
||||
if (error.code === 'GROUP_SUPERUSER_PROTECTED') {
|
||||
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
|
||||
}
|
||||
|
||||
console.error(`⚠️ [Groups] Aktualisierung der Gruppendaten fehlgeschlagen (${groupId}):`, error);
|
||||
res.status(500).json({ error: 'GROUP_UPDATE_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/groups/:groupId', (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
deleteGroup(numericId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_GROUP_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
if (error.code === 'GROUP_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||
}
|
||||
if (error.code === 'GROUP_HAS_MEMBERS') {
|
||||
return res.status(409).json({ error: 'GROUP_HAS_MEMBERS', memberCount: error.memberCount || 0 });
|
||||
}
|
||||
if (error.code === 'GROUP_SUPERUSER_PROTECTED') {
|
||||
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
|
||||
}
|
||||
|
||||
console.error(`⚠️ [Groups] Löschen der Gruppe fehlgeschlagen (${groupId}):`, error);
|
||||
res.status(500).json({ error: 'GROUP_DELETE_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
// Superuser Setup
|
||||
app.get('/api/auth/superuser/status', (req, res) => {
|
||||
const exists = hasSuperuser();
|
||||
@@ -2198,13 +2496,16 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
|
||||
return res.status(400).json({ error: 'Keine passenden Duplikate gefunden' });
|
||||
}
|
||||
|
||||
logRedeployEvent({
|
||||
logStackEvent({
|
||||
stackId: target.canonical.Id,
|
||||
stackName: target.canonical.Name,
|
||||
status: 'started',
|
||||
message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`,
|
||||
endpoint: target.canonical.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
endpointId: target.canonical.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
||||
metadata: {
|
||||
duplicateIds: duplicateIds
|
||||
}
|
||||
});
|
||||
|
||||
const results = [];
|
||||
@@ -2238,13 +2539,16 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
|
||||
|
||||
if (errors.length) {
|
||||
const failedIds = errors.map((entry) => entry.id).join(', ');
|
||||
logRedeployEvent({
|
||||
logStackEvent({
|
||||
stackId: target.canonical.Id,
|
||||
stackName: target.canonical.Name,
|
||||
status: 'error',
|
||||
message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`,
|
||||
endpoint: target.canonical.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
endpointId: target.canonical.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
||||
metadata: {
|
||||
duplicateIds: duplicateIds
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
@@ -2258,13 +2562,16 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
|
||||
});
|
||||
}
|
||||
|
||||
logRedeployEvent({
|
||||
logStackEvent({
|
||||
stackId: target.canonical.Id,
|
||||
stackName: target.canonical.Name,
|
||||
status: 'success',
|
||||
message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`,
|
||||
endpoint: target.canonical.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
endpointId: target.canonical.EndpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE,
|
||||
metadata: {
|
||||
removedDuplicates: results.filter((entry) => entry.status === 'deleted').map((entry) => entry.id)
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -2284,7 +2591,7 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
|
||||
}
|
||||
});
|
||||
|
||||
// Redeploy-Logs abrufen
|
||||
// Event-Logs abrufen
|
||||
app.get('/api/logs', (req, res) => {
|
||||
const perPageParam = req.query.perPage ?? req.query.limit;
|
||||
const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
|
||||
@@ -2293,25 +2600,36 @@ app.get('/api/logs', (req, res) => {
|
||||
const limit = perPage === 'all' ? undefined : perPage;
|
||||
const offset = perPage === 'all' ? 0 : (page - 1) * perPage;
|
||||
|
||||
const { whereClause, params } = buildLogFilter(req.query);
|
||||
const { whereClause, params } = buildEventLogFilter(req.query);
|
||||
const baseQuery = `
|
||||
SELECT
|
||||
id,
|
||||
timestamp,
|
||||
stack_id AS stackId,
|
||||
stack_name AS stackName,
|
||||
category,
|
||||
event_type AS eventType,
|
||||
action,
|
||||
status,
|
||||
severity,
|
||||
entity_type AS entityType,
|
||||
entity_id AS entityId,
|
||||
entity_name AS entityName,
|
||||
actor_type AS actorType,
|
||||
actor_id AS actorId,
|
||||
actor_name AS actorName,
|
||||
source,
|
||||
context_type AS contextType,
|
||||
context_id AS contextId,
|
||||
context_label AS contextLabel,
|
||||
message,
|
||||
endpoint,
|
||||
redeploy_type AS redeployType
|
||||
FROM redeploy_logs
|
||||
metadata
|
||||
FROM event_logs
|
||||
${whereClause}
|
||||
ORDER BY datetime(timestamp) DESC
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM redeploy_logs
|
||||
FROM event_logs
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
@@ -2326,7 +2644,25 @@ app.get('/api/logs', (req, res) => {
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(query);
|
||||
const logs = stmt.all(params);
|
||||
const rawLogs = stmt.all(params);
|
||||
const logs = rawLogs.map((row) => {
|
||||
const metadata = parseJsonColumn(row.metadata);
|
||||
const legacyStack = row.entityType === 'stack' ? (row.entityId ?? null) : null;
|
||||
const legacyStackName = row.entityType === 'stack' ? (row.entityName ?? row.entityId ?? null) : null;
|
||||
const legacyEndpoint = row.contextType === 'endpoint' ? (row.contextId ?? null) : null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
entityId: row.entityId ?? null,
|
||||
actorId: row.actorId ?? null,
|
||||
contextId: row.contextId ?? null,
|
||||
metadata,
|
||||
stackId: legacyStack,
|
||||
stackName: legacyStackName,
|
||||
redeployType: row.eventType ?? null,
|
||||
endpoint: legacyEndpoint
|
||||
};
|
||||
});
|
||||
const total = db.prepare(countQuery).get(params)?.total ?? logs.length;
|
||||
|
||||
res.json({
|
||||
@@ -2336,11 +2672,8 @@ app.get('/api/logs', (req, res) => {
|
||||
perPage: perPage === 'all' ? 'all' : limit
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Abrufen der Redeploy-Logs:', err.message);
|
||||
if (err.message.includes('no such table')) {
|
||||
return res.status(500).json({ error: 'redeploy_logs table nicht gefunden. Bitte Migration ausführen.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Redeploy-Logs' });
|
||||
console.error('❌ Fehler beim Abrufen der Event-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2351,24 +2684,24 @@ app.delete('/api/logs/:id', (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const changes = deleteLogById(id);
|
||||
const changes = deleteEventLogById(id);
|
||||
if (!changes) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
res.json({ success: true, deleted: changes });
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Löschen des Redeploy-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen des Redeploy-Logs' });
|
||||
console.error('❌ Fehler beim Löschen des Event-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen des Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/logs', (req, res) => {
|
||||
try {
|
||||
const deleted = deleteLogsByFilters(req.query);
|
||||
const deleted = deleteEventLogsByFilters(req.query);
|
||||
res.json({ success: true, deleted });
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Löschen der Redeploy-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen der Redeploy-Logs' });
|
||||
console.error('❌ Fehler beim Löschen der Event-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen der Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2379,13 +2712,13 @@ app.get('/api/logs/export', (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = exportLogsByFilters(req.query, format);
|
||||
const payload = exportEventLogsByFilters(req.query, format);
|
||||
res.setHeader('Content-Type', payload.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`);
|
||||
res.send(payload.content);
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Export der Redeploy-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Export der Redeploy-Logs' });
|
||||
console.error('❌ Fehler beim Export der Event-Logs:', err.message);
|
||||
res.status(500).json({ error: 'Fehler beim Export der Logs' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2409,8 +2742,10 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
||||
app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||
|
||||
let endpointId;
|
||||
|
||||
try {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
endpointId = requireActiveEndpointId();
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
const filteredStacks = stacksRes.data.filter(stack => String(stack.EndpointId) === String(endpointId));
|
||||
|
||||
@@ -2427,13 +2762,21 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
|
||||
const stackSummaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
|
||||
const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks';
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'started',
|
||||
message: `Redeploy ALL gestartet für: ${stackSummary}`,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: REDEPLOY_TYPES.ALL,
|
||||
action: 'redeploy-alle',
|
||||
status: 'gestartet',
|
||||
entityType: 'bulk-operation',
|
||||
entityId: 'redeploy-alle',
|
||||
entityName: 'Redeploy ALLE',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: `Redeploy ALLE gestartet für: ${stackSummary}`,
|
||||
metadata: {
|
||||
stacks: stackSummaryList
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
if (!eligibleStacks.length) {
|
||||
@@ -2458,27 +2801,40 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
status: 'success',
|
||||
message: 'Redeploy ALL abgeschlossen',
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: REDEPLOY_TYPES.ALL,
|
||||
action: 'redeploy-alle',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'bulk-operation',
|
||||
entityId: 'redeploy-alle',
|
||||
entityName: 'Redeploy ALLE',
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: 'Redeploy ALLE abgeschlossen',
|
||||
metadata: {
|
||||
processedStacks: stackSummaryList
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Redeploy ALL abgeschlossen' });
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || err.message;
|
||||
logRedeployEvent({
|
||||
stackId: '---',
|
||||
stackName: '---',
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: REDEPLOY_TYPES.ALL,
|
||||
action: 'redeploy-alle',
|
||||
status: 'error',
|
||||
entityType: 'bulk-operation',
|
||||
entityId: 'redeploy-alle',
|
||||
entityName: 'Redeploy ALLE',
|
||||
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
|
||||
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
|
||||
message,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
source: 'system'
|
||||
});
|
||||
console.error('❌ Fehler bei Redeploy ALL:', message);
|
||||
console.error('❌ Fehler bei Redeploy ALLE:', message);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
@@ -2493,8 +2849,10 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' });
|
||||
}
|
||||
|
||||
let endpointId;
|
||||
|
||||
try {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
endpointId = requireActiveEndpointId();
|
||||
const normalizedIds = stackIds.map((id) => String(id));
|
||||
const { filteredStacks } = await loadStackCollections();
|
||||
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack]));
|
||||
@@ -2520,23 +2878,40 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
const summaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`);
|
||||
const summaryText = summaryList.length ? summaryList.join(', ') : 'keine Stacks';
|
||||
|
||||
logRedeployEvent({
|
||||
stackId: stackIds.join(','),
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'started',
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: REDEPLOY_TYPES.SELECTION,
|
||||
action: 'redeploy-auswahl',
|
||||
status: 'gestartet',
|
||||
entityType: 'bulk-operation',
|
||||
entityId: 'redeploy-auswahl',
|
||||
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: `Redeploy Auswahl gestartet für: ${summaryText}`,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
metadata: {
|
||||
requestedStackIds: normalizedIds
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
if (!eligibleStacks.length) {
|
||||
logRedeployEvent({
|
||||
stackId: stackIds.join(','),
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'success',
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: REDEPLOY_TYPES.SELECTION,
|
||||
action: 'redeploy-auswahl',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'bulk-operation',
|
||||
entityId: 'redeploy-auswahl',
|
||||
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
metadata: {
|
||||
requestedStackIds: normalizedIds,
|
||||
skipped: true
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' });
|
||||
}
|
||||
@@ -2558,25 +2933,42 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
logRedeployEvent({
|
||||
stackId: stackIds.join(','),
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'success',
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: REDEPLOY_TYPES.SELECTION,
|
||||
action: 'redeploy-auswahl',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'bulk-operation',
|
||||
entityId: 'redeploy-auswahl',
|
||||
entityName: `Redeploy Auswahl (${stackIds.length})`,
|
||||
contextType: 'endpoint',
|
||||
contextId: String(endpointId),
|
||||
message: 'Redeploy Auswahl abgeschlossen',
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
metadata: {
|
||||
processedStackIds: eligibleStacks.map((stack) => String(stack.Id))
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Redeploy Auswahl abgeschlossen' });
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || err.message;
|
||||
logRedeployEvent({
|
||||
stackId: Array.isArray(stackIds) ? stackIds.join(',') : String(stackIds ?? ''),
|
||||
stackName: `Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
|
||||
status: 'error',
|
||||
const normalized = Array.isArray(stackIds) ? stackIds.map((id) => String(id)) : [];
|
||||
logEvent({
|
||||
category: 'stack',
|
||||
eventType: REDEPLOY_TYPES.SELECTION,
|
||||
action: 'redeploy-auswahl',
|
||||
status: 'fehler',
|
||||
entityType: 'bulk-operation',
|
||||
entityId: 'redeploy-auswahl',
|
||||
entityName: `Redeploy Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
|
||||
contextType: endpointId !== undefined && endpointId !== null ? 'endpoint' : null,
|
||||
contextId: endpointId !== undefined && endpointId !== null ? String(endpointId) : null,
|
||||
message,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
metadata: {
|
||||
requestedStackIds: normalized
|
||||
},
|
||||
source: 'system'
|
||||
});
|
||||
console.error('❌ Fehler bei Redeploy Auswahl:', message);
|
||||
res.status(500).json({ error: message });
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
|
||||
const MAINTENANCE_KEY = 'maintenance_mode';
|
||||
|
||||
@@ -44,6 +45,19 @@ export function isMaintenanceModeActive() {
|
||||
}
|
||||
|
||||
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'Wartungsmodus',
|
||||
action: 'aktivieren',
|
||||
status: 'gestartet',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'System',
|
||||
contextId: 'System',
|
||||
message: 'Wartungsmodus aktiviert',
|
||||
source: 'system'
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
return persistState({
|
||||
active: true,
|
||||
@@ -54,6 +68,19 @@ export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||
}
|
||||
|
||||
export function deactivateMaintenanceMode({ message = null } = {}) {
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'wartungsmodus',
|
||||
action: 'deaktivieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'System',
|
||||
contextId: 'System',
|
||||
message: 'Wartungsmodus deaktiviert',
|
||||
source: 'system'
|
||||
});
|
||||
return persistState({
|
||||
active: false,
|
||||
message,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -34,8 +34,8 @@
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-665b980d.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-5f261614.css">
|
||||
<script type="module" crossorigin src="/assets/index-8d2b4f50.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-11780ccd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+365
-5
@@ -1,9 +1,11 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
import {
|
||||
hashPassword,
|
||||
normalizeAvatarColor,
|
||||
pickRandomAvatarColor,
|
||||
DEFAULT_AVATAR_COLOR
|
||||
DEFAULT_AVATAR_COLOR,
|
||||
SUPERUSER_GROUP_NAME
|
||||
} from '../auth/superuser.js';
|
||||
|
||||
const selectUsersWithGroups = db.prepare(`
|
||||
@@ -42,6 +44,12 @@ const selectUserWithGroupsById = db.prepare(`
|
||||
GROUP BY u.id
|
||||
`);
|
||||
|
||||
const selectUserCredentialsById = db.prepare(`
|
||||
SELECT id, username, email, password_hash, password_salt, avatar_color
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
|
||||
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?');
|
||||
|
||||
@@ -56,12 +64,39 @@ const insertMembershipForUser = db.prepare(`
|
||||
`);
|
||||
|
||||
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
|
||||
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
|
||||
|
||||
const isUserInGroupStatement = db.prepare(`
|
||||
SELECT 1 AS has_membership
|
||||
FROM user_group_memberships m
|
||||
INNER JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE m.user_id = ? AND lower(g.name) = lower(?)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const insertUserStatement = db.prepare(`
|
||||
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateUserCoreStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET username = ?,
|
||||
email = ?,
|
||||
password_hash = ?,
|
||||
password_salt = ?,
|
||||
avatar_color = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateUserActiveStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET is_active = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const parseGroupPairs = (rawPairs) => {
|
||||
if (!rawPairs) {
|
||||
return [];
|
||||
@@ -119,6 +154,18 @@ const insertUserWithGroups = db.transaction(({ username, email, passwordHash, pa
|
||||
return userId;
|
||||
});
|
||||
|
||||
const resolveActorFields = (actor) => {
|
||||
if (!actor || actor.id === undefined || actor.id === null) {
|
||||
return {};
|
||||
}
|
||||
const name = actor.username || actor.email || `User ${actor.id}`;
|
||||
return {
|
||||
actorType: 'user',
|
||||
actorId: String(actor.id),
|
||||
actorName: name
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEmail = (value) => {
|
||||
if (!value) return null;
|
||||
const trimmed = String(value).trim();
|
||||
@@ -128,7 +175,8 @@ const normalizeEmail = (value) => {
|
||||
return trimmed.toLowerCase();
|
||||
};
|
||||
|
||||
export function createUser({ username, email, password, groupId, avatarColor }) {
|
||||
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||
if (!normalizedUsername) {
|
||||
const error = new Error('USERNAME_REQUIRED');
|
||||
@@ -150,6 +198,12 @@ export function createUser({ username, email, password, groupId, avatarColor })
|
||||
throw error;
|
||||
}
|
||||
|
||||
if ((groupRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
if (normalizedEmail && !normalizedEmail.includes('@')) {
|
||||
const error = new Error('INVALID_EMAIL');
|
||||
@@ -200,10 +254,30 @@ export function createUser({ username, email, password, groupId, avatarColor })
|
||||
groupId: numericGroupId
|
||||
});
|
||||
|
||||
return getUserById(userId);
|
||||
const userRecord = getUserById(userId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-angelegt',
|
||||
action: 'anlegen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(userRecord?.id ?? userId),
|
||||
entityName: userRecord?.username ?? normalizedUsername,
|
||||
message: `Benutzer "${normalizedUsername}" angelegt`,
|
||||
metadata: {
|
||||
email: userRecord?.email ?? normalizedEmail ?? null,
|
||||
primaryGroupId: numericGroupId,
|
||||
primaryGroupName: groupRow.name
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return userRecord;
|
||||
}
|
||||
|
||||
export function updateUserGroups(userId, groupIds) {
|
||||
export function updateUserGroups(userId, groupIds, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
@@ -229,10 +303,16 @@ export function updateUserGroups(userId, groupIds) {
|
||||
: [];
|
||||
|
||||
const missingGroupIds = [];
|
||||
const groupDetails = [];
|
||||
normalizedGroupIds.forEach((groupId) => {
|
||||
const groupRow = selectGroupById.get(groupId);
|
||||
if (!groupRow) {
|
||||
missingGroupIds.push(groupId);
|
||||
} else {
|
||||
groupDetails.push({
|
||||
id: groupId,
|
||||
name: groupRow.name
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -244,5 +324,285 @@ export function updateUserGroups(userId, groupIds) {
|
||||
}
|
||||
|
||||
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
|
||||
return getUserById(numericUserId);
|
||||
const updated = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-gruppen-aktualisiert',
|
||||
action: 'gruppe-aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Gruppenzuordnung für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktualisiert`,
|
||||
metadata: {
|
||||
previousGroups: (existingUser.groups || []).map((group) => ({ id: group.id, name: group.name })),
|
||||
groups: updated?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? groupDetails
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateUserDetails(userId, { username, email, password, avatarColor, groupId, groupIds } = {}, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previousUserRecord = getUserById(numericUserId);
|
||||
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : existingUser.username;
|
||||
if (!normalizedUsername) {
|
||||
const error = new Error('USERNAME_REQUIRED');
|
||||
error.code = 'USERNAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const usernameRow = selectUserByUsername.get(normalizedUsername);
|
||||
if (usernameRow && Number(usernameRow.id) !== numericUserId) {
|
||||
const error = new Error('USERNAME_TAKEN');
|
||||
error.code = 'USERNAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedEmail = email === undefined ? existingUser.email : normalizeEmail(email);
|
||||
if (normalizedEmail && !normalizedEmail.includes('@')) {
|
||||
const error = new Error('INVALID_EMAIL');
|
||||
error.code = 'INVALID_EMAIL';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (normalizedEmail) {
|
||||
const emailRow = selectUserByEmail.get(normalizedEmail);
|
||||
if (emailRow && Number(emailRow.id) !== numericUserId) {
|
||||
const error = new Error('EMAIL_TAKEN');
|
||||
error.code = 'EMAIL_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let passwordHash = existingUser.password_hash;
|
||||
let passwordSalt = existingUser.password_salt;
|
||||
if (typeof password === 'string') {
|
||||
const trimmedPassword = password.trim();
|
||||
if (trimmedPassword.length > 0) {
|
||||
const hashed = hashPassword(trimmedPassword);
|
||||
passwordHash = hashed.hash;
|
||||
passwordSalt = hashed.salt;
|
||||
}
|
||||
} else if (password !== undefined && password !== null) {
|
||||
const error = new Error('INVALID_PASSWORD');
|
||||
error.code = 'INVALID_PASSWORD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let colorToPersist = existingUser.avatar_color || DEFAULT_AVATAR_COLOR;
|
||||
if (avatarColor !== undefined) {
|
||||
const candidate = String(avatarColor || '').trim();
|
||||
if (!candidate) {
|
||||
colorToPersist = DEFAULT_AVATAR_COLOR;
|
||||
} else {
|
||||
const normalizedColor = normalizeAvatarColor(candidate);
|
||||
if (!normalizedColor) {
|
||||
const error = new Error('INVALID_AVATAR_COLOR');
|
||||
error.code = 'INVALID_AVATAR_COLOR';
|
||||
throw error;
|
||||
}
|
||||
colorToPersist = normalizedColor;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldUpdateGroups = groupId !== undefined || groupIds !== undefined;
|
||||
let normalizedGroupIds = null;
|
||||
let nextGroups = null;
|
||||
if (shouldUpdateGroups) {
|
||||
const incomingGroupIds = Array.isArray(groupIds)
|
||||
? groupIds
|
||||
: groupId !== undefined && groupId !== null
|
||||
? [groupId]
|
||||
: [];
|
||||
|
||||
normalizedGroupIds = Array.from(
|
||||
new Set(
|
||||
incomingGroupIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const missingGroupIds = [];
|
||||
nextGroups = [];
|
||||
normalizedGroupIds.forEach((value) => {
|
||||
const groupRow = selectGroupById.get(value);
|
||||
if (!groupRow) {
|
||||
missingGroupIds.push(value);
|
||||
} else {
|
||||
nextGroups.push({ id: value, name: groupRow.name });
|
||||
}
|
||||
});
|
||||
|
||||
if (missingGroupIds.length > 0) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
error.missingGroupIds = missingGroupIds;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const performUpdate = db.transaction(() => {
|
||||
updateUserCoreStatement.run(
|
||||
normalizedUsername,
|
||||
normalizedEmail,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
colorToPersist,
|
||||
numericUserId
|
||||
);
|
||||
|
||||
if (normalizedGroupIds !== null) {
|
||||
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
|
||||
}
|
||||
});
|
||||
|
||||
performUpdate();
|
||||
const updatedUser = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-aktualisiert',
|
||||
action: 'aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updatedUser?.username ?? normalizedUsername,
|
||||
message: `Benutzer "${updatedUser?.username ?? normalizedUsername}" aktualisiert`,
|
||||
metadata: {
|
||||
email: updatedUser?.email ?? normalizedEmail ?? null,
|
||||
groupsUpdated: normalizedGroupIds !== null
|
||||
? (updatedUser?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? nextGroups)
|
||||
: undefined,
|
||||
previousGroups: normalizedGroupIds !== null
|
||||
? (previousUserRecord?.groups || []).map((group) => ({ id: group.id, name: group.name }))
|
||||
: undefined,
|
||||
avatarColor: updatedUser?.avatarColor ?? colorToPersist
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
const deleteMembershipsStatement = db.prepare(`
|
||||
DELETE FROM user_group_memberships
|
||||
WHERE user_id = ?
|
||||
`);
|
||||
|
||||
const deleteUserStatement = db.prepare(`
|
||||
DELETE FROM users
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
export function deleteUser(userId, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||
if (superuserMembership && superuserMembership.has_membership) {
|
||||
const error = new Error('USER_SUPERUSER_PROTECTED');
|
||||
error.code = 'USER_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const performDelete = db.transaction(() => {
|
||||
deleteMembershipsStatement.run(numericUserId);
|
||||
deleteUserStatement.run(numericUserId);
|
||||
});
|
||||
|
||||
performDelete();
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-gelöscht',
|
||||
action: 'gelöscht',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Benutzer "${existingUser.username ?? numericUserId}" gelöscht`,
|
||||
metadata: {
|
||||
email: existingUser.email ?? null
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserActiveStatus(userId, isActive, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||
if (superuserMembership && superuserMembership.has_membership && !isActive) {
|
||||
const error = new Error('USER_SUPERUSER_PROTECTED');
|
||||
error.code = 'USER_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedIsActive = isActive ? 1 : 0;
|
||||
updateUserActiveStatement.run(normalizedIsActive, numericUserId);
|
||||
const updated = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: normalizedIsActive ? 'benutzer-aktiviert' : 'benutzer-deaktiviert',
|
||||
action: 'status-aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: normalizedIsActive
|
||||
? `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktiviert`
|
||||
: `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" deaktiviert`,
|
||||
metadata: {
|
||||
isActive: Boolean(normalizedIsActive)
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user