feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes
## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes
## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table
## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* User Management Service for Admin Users
|
||||
* Handles invitations, user CRUD, and role management
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* Create a new admin user invitation
|
||||
* @param {object} params - { email, roleId, invitedById }
|
||||
* @returns {Promise<object>} Created invitation details
|
||||
*/
|
||||
async function createInvitation({ email, roleId, invitedById }) {
|
||||
// Check if email already exists
|
||||
const existingUser = await db('admin_users').where('email', email).first();
|
||||
if (existingUser) {
|
||||
throw new ConflictError('User with this email already exists', 'email');
|
||||
}
|
||||
|
||||
// Check for pending invitation
|
||||
const pendingInvite = await db('admin_invitations')
|
||||
.where('email', email)
|
||||
.whereNull('accepted_at')
|
||||
.where('expires_at', '>', new Date())
|
||||
.first();
|
||||
|
||||
if (pendingInvite) {
|
||||
throw new ConflictError('Pending invitation already exists for this email', 'email');
|
||||
}
|
||||
|
||||
// Validate role exists
|
||||
const role = await db('roles').where('id', roleId).first();
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role', roleId);
|
||||
}
|
||||
|
||||
// Generate secure invitation token (64 characters hex = 32 bytes)
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
const [invitationId] = await db('admin_invitations').insert({
|
||||
email,
|
||||
token,
|
||||
role_id: roleId,
|
||||
invited_by: invitedById,
|
||||
expires_at: expiresAt,
|
||||
created_at: new Date()
|
||||
}).returning('id');
|
||||
|
||||
const id = invitationId?.id || invitationId;
|
||||
|
||||
// Queue invitation email
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
|
||||
await queueEmail(null, email, 'admin_invitation', {
|
||||
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
|
||||
role_name: role.display_name,
|
||||
expires_at: expiresAt.toISOString()
|
||||
});
|
||||
|
||||
await logActivity('admin_invitation_created',
|
||||
{ email, roleId, roleName: role.display_name },
|
||||
null,
|
||||
{ type: 'admin', id: invitedById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin invitation created', { email, roleId, invitedById });
|
||||
|
||||
return { id, email, token, role: role.display_name, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation and create the admin user
|
||||
* @param {object} params - { token, username, password }
|
||||
* @returns {Promise<object>} Created user details
|
||||
*/
|
||||
async function acceptInvitation({ token, username, password }) {
|
||||
const invitation = await db('admin_invitations')
|
||||
.where('token', token)
|
||||
.whereNull('accepted_at')
|
||||
.where('expires_at', '>', new Date())
|
||||
.first();
|
||||
|
||||
if (!invitation) {
|
||||
throw new ValidationError('Invalid or expired invitation');
|
||||
}
|
||||
|
||||
// Check username availability
|
||||
const existingUsername = await db('admin_users').where('username', username).first();
|
||||
if (existingUsername) {
|
||||
throw new ConflictError('Username already taken', 'username');
|
||||
}
|
||||
|
||||
// Check email not taken (race condition protection)
|
||||
const existingEmail = await db('admin_users').where('email', invitation.email).first();
|
||||
if (existingEmail) {
|
||||
throw new ConflictError('Email already registered', 'email');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
|
||||
|
||||
// Create user in transaction
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const [userId] = await trx('admin_users').insert({
|
||||
username,
|
||||
email: invitation.email,
|
||||
password_hash: passwordHash,
|
||||
role_id: invitation.role_id,
|
||||
created_by: invitation.invited_by,
|
||||
is_active: formatBoolean(true),
|
||||
must_change_password: formatBoolean(false),
|
||||
invite_accepted_at: new Date(),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
}).returning('id');
|
||||
|
||||
const id = userId?.id || userId;
|
||||
|
||||
// Mark invitation as accepted
|
||||
await trx('admin_invitations')
|
||||
.where('id', invitation.id)
|
||||
.update({
|
||||
accepted_at: new Date(),
|
||||
accepted_user_id: id
|
||||
});
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
await logActivity('admin_invitation_accepted',
|
||||
{ userId: result, email: invitation.email },
|
||||
null,
|
||||
{ type: 'system', id: null, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin invitation accepted', {
|
||||
userId: result,
|
||||
email: invitation.email,
|
||||
invitationId: invitation.id
|
||||
});
|
||||
|
||||
return { userId: result, email: invitation.email };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all admin users with their roles
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async function getAllAdminUsers() {
|
||||
return db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.leftJoin('admin_users as creator', 'creator.id', 'admin_users.created_by')
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.is_active',
|
||||
'admin_users.last_login',
|
||||
'admin_users.last_login_ip',
|
||||
'admin_users.created_at',
|
||||
'admin_users.updated_at',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name',
|
||||
'creator.username as created_by_username'
|
||||
)
|
||||
.orderBy('admin_users.created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single admin user by ID
|
||||
* @param {number} id - User ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function getAdminUserById(id) {
|
||||
const user = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', id)
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.is_active',
|
||||
'admin_users.last_login',
|
||||
'admin_users.last_login_ip',
|
||||
'admin_users.created_at',
|
||||
'admin_users.updated_at',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update admin user
|
||||
* @param {number} id - User ID to update
|
||||
* @param {object} updates - Fields to update
|
||||
* @param {number} updatedById - ID of user making the update
|
||||
* @returns {Promise<object>} Updated user
|
||||
*/
|
||||
async function updateAdminUser(id, updates, updatedById) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
const allowedUpdates = {};
|
||||
|
||||
if (updates.username !== undefined) {
|
||||
const existing = await db('admin_users')
|
||||
.where('username', updates.username)
|
||||
.whereNot('id', id)
|
||||
.first();
|
||||
if (existing) {
|
||||
throw new ConflictError('Username already taken', 'username');
|
||||
}
|
||||
allowedUpdates.username = updates.username;
|
||||
}
|
||||
|
||||
if (updates.email !== undefined) {
|
||||
const existing = await db('admin_users')
|
||||
.where('email', updates.email)
|
||||
.whereNot('id', id)
|
||||
.first();
|
||||
if (existing) {
|
||||
throw new ConflictError('Email already in use', 'email');
|
||||
}
|
||||
allowedUpdates.email = updates.email;
|
||||
}
|
||||
|
||||
if (updates.role_id !== undefined) {
|
||||
const role = await db('roles').where('id', updates.role_id).first();
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role', updates.role_id);
|
||||
}
|
||||
allowedUpdates.role_id = updates.role_id;
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
allowedUpdates.is_active = formatBoolean(updates.is_active);
|
||||
}
|
||||
|
||||
allowedUpdates.updated_at = new Date();
|
||||
|
||||
await db('admin_users').where('id', id).update(allowedUpdates);
|
||||
|
||||
await logActivity('admin_user_updated',
|
||||
{ userId: id, changes: Object.keys(allowedUpdates) },
|
||||
null,
|
||||
{ type: 'admin', id: updatedById, name: 'system' }
|
||||
);
|
||||
|
||||
return getAdminUserById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate admin user
|
||||
* @param {number} id - User ID to deactivate
|
||||
* @param {number} deactivatedById - ID of user performing deactivation
|
||||
*/
|
||||
async function deactivateAdminUser(id, deactivatedById) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
// Prevent self-deactivation
|
||||
if (id === deactivatedById) {
|
||||
throw new ValidationError('Cannot deactivate your own account');
|
||||
}
|
||||
|
||||
// Check if this is the last super_admin
|
||||
const superAdminRole = await db('roles').where('name', 'super_admin').first();
|
||||
if (user.role_id === superAdminRole?.id) {
|
||||
const superAdminCount = await db('admin_users')
|
||||
.where('role_id', superAdminRole.id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (Number(superAdminCount?.count) <= 1) {
|
||||
throw new ValidationError('Cannot deactivate the last Super Admin');
|
||||
}
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', id).update({
|
||||
is_active: formatBoolean(false),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_user_deactivated',
|
||||
{ userId: id, username: user.username },
|
||||
null,
|
||||
{ type: 'admin', id: deactivatedById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin user deactivated', { userId: id, deactivatedById });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset admin user password (generates new password)
|
||||
* @param {number} id - User ID
|
||||
* @param {number} resetById - ID of user performing reset
|
||||
* @returns {Promise<object>} Result with email and status
|
||||
*/
|
||||
async function resetAdminPassword(id, resetById) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
const newPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
await db('admin_users').where('id', id).update({
|
||||
password_hash: passwordHash,
|
||||
must_change_password: formatBoolean(true),
|
||||
password_changed_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Queue password reset email
|
||||
await queueEmail(null, user.email, 'admin_password_reset', {
|
||||
username: user.username,
|
||||
new_password: newPassword
|
||||
});
|
||||
|
||||
await logActivity('admin_password_reset',
|
||||
{ userId: id, username: user.username },
|
||||
null,
|
||||
{ type: 'admin', id: resetById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin password reset', { userId: id, resetById });
|
||||
|
||||
return { email: user.email, passwordSent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async function getAllRoles() {
|
||||
return db('roles')
|
||||
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
|
||||
.orderBy('priority', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending invitations
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async function getPendingInvitations() {
|
||||
return db('admin_invitations')
|
||||
.join('roles', 'roles.id', 'admin_invitations.role_id')
|
||||
.join('admin_users', 'admin_users.id', 'admin_invitations.invited_by')
|
||||
.whereNull('admin_invitations.accepted_at')
|
||||
.where('admin_invitations.expires_at', '>', new Date())
|
||||
.select(
|
||||
'admin_invitations.id',
|
||||
'admin_invitations.email',
|
||||
'admin_invitations.expires_at',
|
||||
'admin_invitations.created_at',
|
||||
'roles.display_name as role_name',
|
||||
'admin_users.username as invited_by'
|
||||
)
|
||||
.orderBy('admin_invitations.created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/delete an invitation
|
||||
* @param {number} id - Invitation ID
|
||||
* @param {number} cancelledById - ID of user cancelling
|
||||
*/
|
||||
async function cancelInvitation(id, cancelledById) {
|
||||
const invitation = await db('admin_invitations').where('id', id).first();
|
||||
if (!invitation) {
|
||||
throw new NotFoundError('Invitation', id);
|
||||
}
|
||||
|
||||
await db('admin_invitations').where('id', id).del();
|
||||
|
||||
await logActivity('admin_invitation_cancelled',
|
||||
{ invitationId: id, email: invitation.email },
|
||||
null,
|
||||
{ type: 'admin', id: cancelledById, name: 'system' }
|
||||
);
|
||||
|
||||
logger.info('Admin invitation cancelled', { invitationId: id, cancelledById });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an invitation token
|
||||
* @param {string} token - Invitation token
|
||||
* @returns {Promise<object|null>} Invitation details if valid
|
||||
*/
|
||||
async function validateInvitationToken(token) {
|
||||
const invitation = await db('admin_invitations')
|
||||
.join('roles', 'roles.id', 'admin_invitations.role_id')
|
||||
.where('admin_invitations.token', token)
|
||||
.whereNull('admin_invitations.accepted_at')
|
||||
.where('admin_invitations.expires_at', '>', new Date())
|
||||
.select(
|
||||
'admin_invitations.email',
|
||||
'admin_invitations.expires_at',
|
||||
'roles.display_name as role_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
return invitation || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInvitation,
|
||||
acceptInvitation,
|
||||
getAllAdminUsers,
|
||||
getAdminUserById,
|
||||
updateAdminUser,
|
||||
deactivateAdminUser,
|
||||
resetAdminPassword,
|
||||
getAllRoles,
|
||||
getPendingInvitations,
|
||||
cancelInvitation,
|
||||
validateInvitationToken
|
||||
};
|
||||
Reference in New Issue
Block a user