Files
vendoo/app/modules/users/service.mjs
T

109 lines
5.8 KiB
JavaScript

import { randomBytes } from 'node:crypto';
import {
auditLog, createAuthToken, createUser, deleteUser, getLoginHistory, getUser, getUserByEmail,
getUsers, setPassword, updateUser, validatePasswordStrength, listInvitations, getInvitation, revokeInvitation,
} from '../../core/identity/identity-store.mjs';
import { permissionsForRole, getRoleDefinitions } from '../../../lib/security.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
const ROLES = new Set(['admin', 'manager', 'editor', 'publisher', 'viewer']);
function fail(message, status = 400, code = 'USER_OPERATION_FAILED') {
throw new PlatformError(message, { status, code, expose: true });
}
function publicUser(user) {
if (!user) return null;
const { password_hash: _passwordHash, ...safe } = user;
return safe;
}
export function createUsersService() {
return Object.freeze({
list() { return getUsers().map(publicUser); },
find(id) { return publicUser(getUser(id)); },
findByEmail(email) { return publicUser(getUserByEmail(email)); },
currentIdentity(userId, csrf) {
const user = getUser(userId);
if (!user) fail('Benutzer nicht gefunden.', 404, 'USER_NOT_FOUND');
const initials = (user.name || user.email).split(/[\s@]/).map(word => word[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join('');
return {
id: user.id, email: user.email, name: user.name, role: user.role,
avatar_color: user.avatar_color, initials, permissions: [...permissionsForRole(user.role)],
role_definition: getRoleDefinitions().find(role => role.key === user.role) || null,
csrf,
};
},
updateProfile(userId, data) {
const updated = updateUser(userId, { name: data.name });
if (!updated) fail('Benutzer nicht gefunden.', 404, 'USER_NOT_FOUND');
return publicUser(updated);
},
prepareInvite({ email, name, role, method, createdBy }) {
const normalizedRole = ROLES.has(role) ? role : 'viewer';
if (getUserByEmail(email)) fail('User existiert bereits', 400, 'USER_EXISTS');
if (method === 'auto_password') {
const password = `Vd!${randomBytes(9).toString('base64url')}9a`;
const user = createUser(email, name || email.split('@')[0], normalizedRole, createdBy, password);
return { mode: 'auto_password', user: publicUser(user), password, role: normalizedRole };
}
const invite = createAuthToken(email, 'invite', normalizedRole, createdBy);
return { mode: 'magic_link', invite, email, name: name || '', role: normalizedRole };
},
resendInvite({ userId, createdBy }) {
const user = getUser(userId);
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
return { user: publicUser(user), invite: createAuthToken(user.email, 'invite', user.role, createdBy) };
},
invitations(limit = 100) { return listInvitations(limit); },
resendPendingInvitation({ invitationId, createdBy }) {
const invitation = getInvitation(invitationId);
if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND');
if (invitation.status !== 'pending' && invitation.status !== 'expired') fail('Diese Einladung kann nicht erneut versendet werden.', 409, 'INVITATION_NOT_RESENDABLE');
const role = ROLES.has(invitation.role) ? invitation.role : 'viewer';
return { invitation, invite: createAuthToken(invitation.email, 'invite', role, createdBy), role };
},
revokePendingInvitation(invitationId) {
const invitation = revokeInvitation(invitationId);
if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND');
return invitation;
},
updateManagedUser({ actor, userId, patch }) {
const existing = getUser(userId);
if (!existing) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
if (Number(actor.id) === Number(userId) && patch.role && patch.role !== 'admin') fail('Du kannst dir selbst nicht die Admin-Rolle entziehen');
const activeAdmins = getUsers().filter(user => user.role === 'admin' && user.active).length;
const removesLastAdmin = existing.role === 'admin' && existing.active && activeAdmins <= 1
&& ((patch.role && patch.role !== 'admin') || Number(patch.active) === 0);
if (removesLastAdmin) fail('Der letzte aktive Administrator darf nicht deaktiviert oder herabgestuft werden.');
const normalized = {};
if (patch.name !== undefined) normalized.name = patch.name;
if (patch.role !== undefined) normalized.role = ROLES.has(patch.role) ? patch.role : 'viewer';
if (patch.active !== undefined) normalized.active = Number(patch.active) === 0 ? 0 : 1;
if (patch.avatar_color !== undefined) normalized.avatar_color = patch.avatar_color;
return publicUser(updateUser(userId, normalized));
},
deleteManagedUser({ actor, userId }) {
if (Number(actor.id) === Number(userId)) fail('Du kannst dich nicht selbst löschen');
const user = getUser(userId);
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
if (user.role === 'admin' && user.active && getUsers().filter(entry => entry.role === 'admin' && entry.active).length <= 1) {
fail('Der letzte aktive Administrator darf nicht gelöscht werden.');
}
deleteUser(userId);
return publicUser(user);
},
resetPassword({ userId, password }) {
const check = validatePasswordStrength(password);
if (!check.valid) fail(`Passwort benötigt ${check.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
const user = getUser(userId);
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
setPassword(userId, password);
return publicUser(user);
},
loginHistory(userId, limit = 30) { return getLoginHistory(userId, limit); },
health() { return { ok: true, users: getUsers().length }; },
auditLog,
});
}