988 lines
27 KiB
JavaScript
988 lines
27 KiB
JavaScript
import { db } from '../db/index.js';
|
|
import { logEvent } from '../logging/eventLogs.js';
|
|
import {
|
|
generateSecurityPhraseWords,
|
|
encryptSecurityPhrase,
|
|
decryptSecurityPhrase,
|
|
canonicalizeWords,
|
|
canonicalizePhraseInput
|
|
} from './securityPhrase.js';
|
|
import {
|
|
hashPassword,
|
|
normalizeAvatarColor,
|
|
pickRandomAvatarColor,
|
|
DEFAULT_AVATAR_COLOR,
|
|
SUPERUSER_GROUP_NAME
|
|
} from '../auth/superuser.js';
|
|
|
|
const selectUsersWithGroups = db.prepare(`
|
|
SELECT
|
|
u.id,
|
|
u.username,
|
|
u.email,
|
|
u.is_active,
|
|
u.avatar_color,
|
|
u.last_login,
|
|
u.created_at,
|
|
u.updated_at,
|
|
u.security_phrase_downloaded_at,
|
|
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
|
FROM users u
|
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
|
LEFT JOIN user_groups g ON g.id = m.group_id
|
|
GROUP BY u.id
|
|
ORDER BY u.username COLLATE NOCASE
|
|
`);
|
|
|
|
const selectUserWithGroupsById = db.prepare(`
|
|
SELECT
|
|
u.id,
|
|
u.username,
|
|
u.email,
|
|
u.is_active,
|
|
u.avatar_color,
|
|
u.last_login,
|
|
u.created_at,
|
|
u.updated_at,
|
|
u.security_phrase_content,
|
|
u.security_phrase_iv,
|
|
u.security_phrase_tag,
|
|
u.security_phrase_downloaded_at,
|
|
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
|
FROM users u
|
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
|
LEFT JOIN user_groups g ON g.id = m.group_id
|
|
WHERE u.id = ?
|
|
GROUP BY u.id
|
|
`);
|
|
|
|
const 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 = ?');
|
|
|
|
const deleteMembershipsByUser = db.prepare(`
|
|
DELETE FROM user_group_memberships
|
|
WHERE user_id = ?
|
|
`);
|
|
|
|
const insertMembershipForUser = db.prepare(`
|
|
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
|
|
VALUES (?, ?)
|
|
`);
|
|
|
|
const selectSecurityPhraseByUsername = db.prepare(`
|
|
SELECT
|
|
id,
|
|
username,
|
|
is_active,
|
|
security_phrase_content AS content,
|
|
security_phrase_iv AS iv,
|
|
security_phrase_tag AS tag,
|
|
security_phrase_downloaded_at AS downloaded_at
|
|
FROM users
|
|
WHERE lower(username) = lower(?)
|
|
LIMIT 1
|
|
`);
|
|
|
|
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,
|
|
security_phrase_content,
|
|
security_phrase_iv,
|
|
security_phrase_tag,
|
|
security_phrase_downloaded_at,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, 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 updateUserPasswordStatement = db.prepare(`
|
|
UPDATE users
|
|
SET password_hash = ?,
|
|
password_salt = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`);
|
|
|
|
const parseGroupPairs = (rawPairs) => {
|
|
if (!rawPairs) {
|
|
return [];
|
|
}
|
|
return rawPairs
|
|
.split('|||')
|
|
.map((entry) => {
|
|
const [idPart, namePart] = entry.split('::');
|
|
const groupName = String(namePart || '').trim();
|
|
if (!groupName) {
|
|
return null;
|
|
}
|
|
const numericId = Number(idPart);
|
|
return {
|
|
id: Number.isFinite(numericId) ? numericId : null,
|
|
name: groupName
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
};
|
|
|
|
const sanitizeUserRecord = (row) => ({
|
|
id: row.id,
|
|
username: row.username,
|
|
email: row.email || null,
|
|
isActive: Boolean(row.is_active),
|
|
avatarColor: row.avatar_color || null,
|
|
lastLogin: row.last_login || null,
|
|
createdAt: row.created_at || null,
|
|
updatedAt: row.updated_at || null,
|
|
groups: parseGroupPairs(row.group_pairs),
|
|
securityPhraseDownloadedAt: row.security_phrase_downloaded_at || null
|
|
});
|
|
|
|
export function listUsers() {
|
|
const rows = selectUsersWithGroups.all();
|
|
return rows.map(sanitizeUserRecord);
|
|
}
|
|
|
|
export function getUserById(userId) {
|
|
const row = selectUserWithGroupsById.get(userId);
|
|
return row ? sanitizeUserRecord(row) : null;
|
|
}
|
|
|
|
const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
|
|
deleteMembershipsByUser.run(userId);
|
|
groupIds.forEach((groupId) => {
|
|
insertMembershipForUser.run(userId, groupId);
|
|
});
|
|
});
|
|
|
|
const insertUserWithGroups = db.transaction(({
|
|
username,
|
|
email,
|
|
passwordHash,
|
|
passwordSalt,
|
|
avatarColor,
|
|
groupId,
|
|
securityPhrase
|
|
}) => {
|
|
const phraseToPersist = securityPhrase && typeof securityPhrase === 'object'
|
|
? {
|
|
content: securityPhrase.content ?? null,
|
|
iv: securityPhrase.iv ?? null,
|
|
tag: securityPhrase.tag ?? null
|
|
}
|
|
: { content: null, iv: null, tag: null };
|
|
|
|
const result = insertUserStatement.run(
|
|
username,
|
|
email,
|
|
passwordHash,
|
|
passwordSalt,
|
|
avatarColor,
|
|
phraseToPersist.content,
|
|
phraseToPersist.iv,
|
|
phraseToPersist.tag
|
|
);
|
|
const userId = Number(result.lastInsertRowid);
|
|
applyUserGroupAssignments(userId, [groupId]);
|
|
return userId;
|
|
});
|
|
|
|
const selectSecurityPhraseByUserId = db.prepare(`
|
|
SELECT
|
|
id,
|
|
username,
|
|
security_phrase_content AS content,
|
|
security_phrase_iv AS iv,
|
|
security_phrase_tag AS tag,
|
|
security_phrase_downloaded_at AS downloaded_at
|
|
FROM users
|
|
WHERE id = ?
|
|
`);
|
|
|
|
const updateSecurityPhraseStatement = db.prepare(`
|
|
UPDATE users
|
|
SET
|
|
security_phrase_content = ?,
|
|
security_phrase_iv = ?,
|
|
security_phrase_tag = ?,
|
|
security_phrase_downloaded_at = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`);
|
|
|
|
const markSecurityPhraseDownloadedStatement = db.prepare(`
|
|
UPDATE users
|
|
SET security_phrase_downloaded_at = COALESCE(security_phrase_downloaded_at, CURRENT_TIMESTAMP),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`);
|
|
|
|
const selectUsersMissingSecurityPhraseStatement = db.prepare(`
|
|
SELECT id
|
|
FROM users
|
|
WHERE security_phrase_content IS NULL
|
|
OR security_phrase_iv IS NULL
|
|
OR security_phrase_tag IS NULL
|
|
`);
|
|
|
|
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();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return trimmed.toLowerCase();
|
|
};
|
|
|
|
export function getUserSecurityPhrase(userId, options = {}) {
|
|
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 { actor = null, allowAutoGenerate = true } = options;
|
|
|
|
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
|
if (!record) {
|
|
const error = new Error('USER_NOT_FOUND');
|
|
error.code = 'USER_NOT_FOUND';
|
|
throw error;
|
|
}
|
|
|
|
const words = decryptSecurityPhrase(record);
|
|
|
|
if (allowAutoGenerate && (!Array.isArray(words) || words.length === 0)) {
|
|
return renewUserSecurityPhrase(numericUserId, { actor, suppressLog: true });
|
|
}
|
|
|
|
return {
|
|
userId: numericUserId,
|
|
username: record.username,
|
|
words,
|
|
downloadedAt: record.downloaded_at || null
|
|
};
|
|
}
|
|
|
|
export function renewUserSecurityPhrase(userId, options = {}) {
|
|
const actor = options.actor || null;
|
|
const suppressLog = Boolean(options.suppressLog);
|
|
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 previousPhraseRecord = selectSecurityPhraseByUserId.get(numericUserId);
|
|
|
|
const words = generateSecurityPhraseWords(8);
|
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
|
|
|
updateSecurityPhraseStatement.run(
|
|
encryptedPhrase.content,
|
|
encryptedPhrase.iv,
|
|
encryptedPhrase.tag,
|
|
numericUserId
|
|
);
|
|
|
|
if (!suppressLog) {
|
|
logEvent({
|
|
category: 'benutzer',
|
|
eventType: 'benutzer-sicherheitsschluessel-erneuert',
|
|
action: 'sicherheitsschluessel-erneuern',
|
|
status: 'erfolgreich',
|
|
entityType: 'benutzer',
|
|
entityId: String(numericUserId),
|
|
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
|
message: `Sicherheitsschlüssel für Benutzer "${existingUser.username ?? numericUserId}" erneuert`,
|
|
metadata: {
|
|
previousDownloadedAt: previousPhraseRecord?.downloaded_at ?? null
|
|
},
|
|
...resolveActorFields(actor)
|
|
});
|
|
}
|
|
|
|
return {
|
|
userId: numericUserId,
|
|
username: existingUser.username,
|
|
words,
|
|
downloadedAt: null
|
|
};
|
|
}
|
|
|
|
export function markSecurityPhraseDownloaded(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 record = selectSecurityPhraseByUserId.get(numericUserId);
|
|
if (!record) {
|
|
const error = new Error('USER_NOT_FOUND');
|
|
error.code = 'USER_NOT_FOUND';
|
|
throw error;
|
|
}
|
|
|
|
markSecurityPhraseDownloadedStatement.run(numericUserId);
|
|
|
|
const updatedRecord = selectSecurityPhraseByUserId.get(numericUserId) || record;
|
|
const downloadedAt = updatedRecord?.downloaded_at || null;
|
|
|
|
logEvent({
|
|
category: 'benutzer',
|
|
eventType: 'benutzer-sicherheitsschluessel-heruntergeladen',
|
|
action: 'sicherheitsschluessel-herunterladen',
|
|
status: 'erfolgreich',
|
|
entityType: 'benutzer',
|
|
entityId: String(numericUserId),
|
|
entityName: record.username ?? `ID ${numericUserId}`,
|
|
message: `Sicherheitsschlüssel für Benutzer "${record.username ?? numericUserId}" heruntergeladen`,
|
|
metadata: {
|
|
downloadedAt
|
|
},
|
|
...resolveActorFields(actor)
|
|
});
|
|
|
|
return {
|
|
userId: numericUserId,
|
|
username: record.username,
|
|
downloadedAt
|
|
};
|
|
}
|
|
|
|
export function verifySecurityPhraseForUsername(username, phraseInput) {
|
|
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
|
if (!normalizedUsername) {
|
|
const error = new Error('USERNAME_REQUIRED');
|
|
error.code = 'USERNAME_REQUIRED';
|
|
throw error;
|
|
}
|
|
|
|
const record = selectSecurityPhraseByUsername.get(normalizedUsername);
|
|
if (!record) {
|
|
const error = new Error('USER_NOT_FOUND');
|
|
error.code = 'USER_NOT_FOUND';
|
|
throw error;
|
|
}
|
|
|
|
const candidateCanonical = canonicalizePhraseInput(phraseInput);
|
|
if (!candidateCanonical) {
|
|
const error = new Error('PHRASE_REQUIRED');
|
|
error.code = 'PHRASE_REQUIRED';
|
|
throw error;
|
|
}
|
|
|
|
const words = decryptSecurityPhrase(record);
|
|
if (!Array.isArray(words) || words.length === 0) {
|
|
const error = new Error('PHRASE_NOT_INITIALIZED');
|
|
error.code = 'PHRASE_NOT_INITIALIZED';
|
|
throw error;
|
|
}
|
|
|
|
const storedCanonical = canonicalizeWords(words);
|
|
if (!storedCanonical) {
|
|
const error = new Error('PHRASE_NOT_INITIALIZED');
|
|
error.code = 'PHRASE_NOT_INITIALIZED';
|
|
throw error;
|
|
}
|
|
|
|
if (storedCanonical !== candidateCanonical) {
|
|
const error = new Error('PHRASE_MISMATCH');
|
|
error.code = 'PHRASE_MISMATCH';
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
userId: record.id,
|
|
username: record.username,
|
|
isActive: Boolean(record.is_active),
|
|
words
|
|
};
|
|
}
|
|
|
|
export function setUserPassword(userId, newPassword, options = {}) {
|
|
const actor = options.actor || null;
|
|
const resetMethod = options.resetMethod || 'security-phrase';
|
|
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;
|
|
}
|
|
|
|
let passwordHash;
|
|
let passwordSalt;
|
|
try {
|
|
const hashed = hashPassword(newPassword);
|
|
passwordHash = hashed.hash;
|
|
passwordSalt = hashed.salt;
|
|
} catch (err) {
|
|
if (err && err.code) {
|
|
throw err;
|
|
}
|
|
const error = new Error('INVALID_PASSWORD');
|
|
error.code = 'INVALID_PASSWORD';
|
|
throw error;
|
|
}
|
|
|
|
updateUserPasswordStatement.run(passwordHash, passwordSalt, numericUserId);
|
|
const updated = getUserById(numericUserId);
|
|
|
|
logEvent({
|
|
category: 'benutzer',
|
|
eventType: 'benutzer-passwort-zurueckgesetzt',
|
|
action: 'passwort-zuruecksetzen',
|
|
status: 'erfolgreich',
|
|
entityType: 'benutzer',
|
|
entityId: String(numericUserId),
|
|
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
|
message: `Passwort für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" zurückgesetzt`,
|
|
metadata: {
|
|
resetMethod
|
|
},
|
|
...resolveActorFields(actor)
|
|
});
|
|
|
|
return updated;
|
|
}
|
|
|
|
export function ensureSecurityPhrasesForExistingUsers() {
|
|
const rows = selectUsersMissingSecurityPhraseStatement.all();
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
let processed = 0;
|
|
const initialize = db.transaction((users) => {
|
|
users.forEach((entry) => {
|
|
if (!entry || entry.id === undefined || entry.id === null) {
|
|
return;
|
|
}
|
|
const words = generateSecurityPhraseWords(8);
|
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
|
updateSecurityPhraseStatement.run(
|
|
encryptedPhrase.content,
|
|
encryptedPhrase.iv,
|
|
encryptedPhrase.tag,
|
|
entry.id
|
|
);
|
|
processed += 1;
|
|
});
|
|
});
|
|
|
|
initialize(rows);
|
|
return processed;
|
|
}
|
|
|
|
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');
|
|
error.code = 'USERNAME_REQUIRED';
|
|
throw error;
|
|
}
|
|
|
|
const numericGroupId = Number(groupId);
|
|
if (!Number.isFinite(numericGroupId) || numericGroupId <= 0) {
|
|
const error = new Error('INVALID_GROUP_ID');
|
|
error.code = 'INVALID_GROUP_ID';
|
|
throw error;
|
|
}
|
|
|
|
const groupRow = selectGroupById.get(numericGroupId);
|
|
if (!groupRow) {
|
|
const error = new Error('GROUP_NOT_FOUND');
|
|
error.code = 'GROUP_NOT_FOUND';
|
|
throw error;
|
|
}
|
|
|
|
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');
|
|
error.code = 'INVALID_EMAIL';
|
|
throw error;
|
|
}
|
|
|
|
const existingUsername = selectUserByUsername.get(normalizedUsername);
|
|
if (existingUsername) {
|
|
const error = new Error('USERNAME_TAKEN');
|
|
error.code = 'USERNAME_TAKEN';
|
|
throw error;
|
|
}
|
|
|
|
if (normalizedEmail) {
|
|
const existingEmail = selectUserByEmail.get(normalizedEmail);
|
|
if (existingEmail) {
|
|
const error = new Error('EMAIL_TAKEN');
|
|
error.code = 'EMAIL_TAKEN';
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let passwordHash;
|
|
let passwordSalt;
|
|
try {
|
|
const hashed = hashPassword(password);
|
|
passwordHash = hashed.hash;
|
|
passwordSalt = hashed.salt;
|
|
} catch (err) {
|
|
if (err && err.code) {
|
|
throw err;
|
|
}
|
|
const error = new Error('INVALID_PASSWORD');
|
|
error.code = 'INVALID_PASSWORD';
|
|
throw error;
|
|
}
|
|
|
|
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
|
|
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
|
|
|
const securityPhraseWords = generateSecurityPhraseWords(8);
|
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(securityPhraseWords);
|
|
const securityPhrasePayload = {
|
|
content: encryptedPhrase.content,
|
|
iv: encryptedPhrase.iv,
|
|
tag: encryptedPhrase.tag
|
|
};
|
|
|
|
const userId = insertUserWithGroups({
|
|
username: normalizedUsername,
|
|
email: normalizedEmail,
|
|
passwordHash,
|
|
passwordSalt,
|
|
avatarColor: avatarColorToPersist,
|
|
groupId: numericGroupId,
|
|
securityPhrase: securityPhrasePayload
|
|
});
|
|
|
|
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, 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 = getUserById(numericUserId);
|
|
if (!existingUser) {
|
|
const error = new Error('USER_NOT_FOUND');
|
|
error.code = 'USER_NOT_FOUND';
|
|
throw error;
|
|
}
|
|
|
|
const normalizedGroupIds = Array.isArray(groupIds)
|
|
? Array.from(
|
|
new Set(
|
|
groupIds
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
)
|
|
)
|
|
: [];
|
|
|
|
const missingGroupIds = [];
|
|
const groupDetails = [];
|
|
normalizedGroupIds.forEach((groupId) => {
|
|
const groupRow = selectGroupById.get(groupId);
|
|
if (!groupRow) {
|
|
missingGroupIds.push(groupId);
|
|
} else {
|
|
groupDetails.push({
|
|
id: groupId,
|
|
name: groupRow.name
|
|
});
|
|
}
|
|
});
|
|
|
|
if (missingGroupIds.length > 0) {
|
|
const error = new Error('GROUP_NOT_FOUND');
|
|
error.code = 'GROUP_NOT_FOUND';
|
|
error.missingGroupIds = missingGroupIds;
|
|
throw error;
|
|
}
|
|
|
|
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
|
|
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;
|
|
}
|