Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
109 lines
5.8 KiB
JavaScript
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,
|
|
});
|
|
}
|