511 Zeilen
17 KiB
TypeScript
511 Zeilen
17 KiB
TypeScript
import { Router, Response, NextFunction } from 'express'
|
|
import { body, validationResult } from 'express-validator'
|
|
import bcrypt from 'bcrypt'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { db, encryptedDb } from '../config/secureDatabase'
|
|
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
|
import { requirePermission } from '../middleware/roleAuth'
|
|
import { User, UserRole } from '@skillmate/shared'
|
|
import { FieldEncryption } from '../services/encryption'
|
|
import { emailService } from '../services/emailService'
|
|
import { logger } from '../utils/logger'
|
|
|
|
const router = Router()
|
|
|
|
// Get all users (admin only)
|
|
router.get('/', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const users = db.prepare(`
|
|
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
|
|
FROM users
|
|
ORDER BY username
|
|
`).all() as any[]
|
|
|
|
// Decrypt email addresses (handle decryption failures)
|
|
const decryptedUsers = users.map(user => {
|
|
let decryptedEmail = user.email
|
|
|
|
// Check if email looks encrypted (encrypted strings are typically longer and contain special characters)
|
|
if (user.email && user.email.includes('U2FsdGVkX1')) {
|
|
try {
|
|
decryptedEmail = FieldEncryption.decrypt(user.email) || user.email
|
|
} catch (error) {
|
|
logger.warn(`Failed to decrypt email for user ${user.username}, using raw value`)
|
|
// For compatibility with old unencrypted data or different encryption keys
|
|
decryptedEmail = user.email
|
|
}
|
|
}
|
|
|
|
return {
|
|
...user,
|
|
email: decryptedEmail,
|
|
isActive: Boolean(user.is_active),
|
|
lastLogin: user.last_login ? new Date(user.last_login) : null,
|
|
createdAt: new Date(user.created_at),
|
|
updatedAt: new Date(user.updated_at),
|
|
employeeId: user.employee_id
|
|
}
|
|
})
|
|
|
|
res.json({ success: true, data: decryptedUsers })
|
|
} catch (error) {
|
|
logger.error('Error fetching users:', error)
|
|
next(error)
|
|
}
|
|
})
|
|
|
|
// Update user role (admin only)
|
|
router.put('/:id/role',
|
|
authenticate,
|
|
requirePermission('users:update'),
|
|
[
|
|
body('role').isIn(['admin', 'superuser', 'user'])
|
|
],
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { message: 'Invalid input', details: errors.array() }
|
|
})
|
|
}
|
|
|
|
const { id } = req.params
|
|
const { role } = req.body
|
|
|
|
// Check if user exists
|
|
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
|
if (!existingUser) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: { message: 'User not found' }
|
|
})
|
|
}
|
|
|
|
// Prevent changing admin user role
|
|
if (existingUser.username === 'admin' && role !== 'admin') {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: { message: 'Cannot change admin user role' }
|
|
})
|
|
}
|
|
|
|
// Update role
|
|
db.prepare(`
|
|
UPDATE users SET role = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(role, new Date().toISOString(), id)
|
|
|
|
logger.info(`User role updated: ${existingUser.username} -> ${role}`)
|
|
res.json({ success: true, message: 'Role updated successfully' })
|
|
} catch (error) {
|
|
logger.error('Error updating user role:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Bulk create users from employees
|
|
router.post('/bulk-create-from-employees',
|
|
authenticate,
|
|
requirePermission('users:create'),
|
|
[
|
|
body('employeeIds').isArray({ min: 1 }),
|
|
body('role').isIn(['admin', 'superuser', 'user'])
|
|
],
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { message: 'Invalid input', details: errors.array() }
|
|
})
|
|
}
|
|
|
|
const { employeeIds, role } = req.body as { employeeIds: string[]; role: UserRole }
|
|
const results: any[] = []
|
|
|
|
for (const employeeId of employeeIds) {
|
|
try {
|
|
const emp = encryptedDb.getEmployee(employeeId) as any
|
|
if (!emp) {
|
|
results.push({ employeeId, status: 'skipped', reason: 'employee_not_found' })
|
|
continue
|
|
}
|
|
|
|
const existsByEmployee = db.prepare('SELECT id FROM users WHERE employee_id = ?').get(employeeId)
|
|
if (existsByEmployee) {
|
|
results.push({ employeeId, status: 'skipped', reason: 'user_exists' })
|
|
continue
|
|
}
|
|
|
|
const baseUsername = (emp.email ? String(emp.email).split('@')[0] : `${emp.first_name}.${emp.last_name}`)
|
|
.toLowerCase().replace(/\s+/g, '')
|
|
let finalUsername = baseUsername
|
|
let i = 1
|
|
while (db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)) {
|
|
finalUsername = `${baseUsername}${i++}`
|
|
}
|
|
|
|
const email: string | null = emp.email || null
|
|
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
|
|
const hashedPassword = await bcrypt.hash(tempPassword, 12)
|
|
const now = new Date().toISOString()
|
|
const userId = uuidv4()
|
|
|
|
db.prepare(`
|
|
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
userId,
|
|
finalUsername,
|
|
email ? FieldEncryption.encrypt(email) : null,
|
|
email ? FieldEncryption.hash(email) : null,
|
|
hashedPassword,
|
|
role,
|
|
employeeId,
|
|
1,
|
|
now,
|
|
now
|
|
)
|
|
|
|
results.push({ employeeId, status: 'created', username: finalUsername, temporaryPassword: tempPassword })
|
|
} catch (err: any) {
|
|
logger.error('Bulk create error for employee ' + employeeId, err)
|
|
results.push({ employeeId, status: 'error', reason: err?.message || 'unknown_error' })
|
|
}
|
|
}
|
|
|
|
return res.status(201).json({ success: true, data: { results } })
|
|
} catch (error) {
|
|
logger.error('Error in bulk create from employees:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Update user status (admin only)
|
|
router.put('/:id/status',
|
|
authenticate,
|
|
requirePermission('users:update'),
|
|
[
|
|
body('isActive').isBoolean()
|
|
],
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { message: 'Invalid input', details: errors.array() }
|
|
})
|
|
}
|
|
|
|
const { id } = req.params
|
|
const { isActive } = req.body
|
|
|
|
// Check if user exists
|
|
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
|
if (!existingUser) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: { message: 'User not found' }
|
|
})
|
|
}
|
|
|
|
// Prevent deactivating admin user
|
|
if (existingUser.username === 'admin' && !isActive) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: { message: 'Cannot deactivate admin user' }
|
|
})
|
|
}
|
|
|
|
// Update status
|
|
db.prepare(`
|
|
UPDATE users SET is_active = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(isActive ? 1 : 0, new Date().toISOString(), id)
|
|
|
|
logger.info(`User status updated: ${existingUser.username} -> ${isActive ? 'active' : 'inactive'}`)
|
|
res.json({ success: true, message: 'Status updated successfully' })
|
|
} catch (error) {
|
|
logger.error('Error updating user status:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Reset user password (admin only)
|
|
router.post('/:id/reset-password',
|
|
authenticate,
|
|
requirePermission('users:update'),
|
|
[
|
|
body('newPassword').optional().isLength({ min: 8 })
|
|
],
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { message: 'Invalid input', details: errors.array() }
|
|
})
|
|
}
|
|
|
|
const { id } = req.params
|
|
const { newPassword } = req.body
|
|
|
|
// Check if user exists
|
|
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
|
if (!existingUser) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: { message: 'User not found' }
|
|
})
|
|
}
|
|
|
|
// Generate password
|
|
const password = newPassword || `Temp${Math.random().toString(36).slice(-8)}!@#`
|
|
const hashedPassword = await bcrypt.hash(password, 12)
|
|
|
|
// Update password
|
|
db.prepare(`
|
|
UPDATE users SET password = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(hashedPassword, new Date().toISOString(), id)
|
|
|
|
logger.info(`Password reset for user: ${existingUser.username}`)
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Password reset successfully',
|
|
data: { temporaryPassword: newPassword ? undefined : password }
|
|
})
|
|
} catch (error) {
|
|
logger.error('Error resetting password:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Delete user (admin only)
|
|
router.delete('/:id',
|
|
authenticate,
|
|
requirePermission('users:delete'),
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const { id } = req.params
|
|
|
|
// Check if user exists
|
|
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
|
if (!existingUser) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: { message: 'User not found' }
|
|
})
|
|
}
|
|
|
|
// Prevent deleting admin user
|
|
if (existingUser.username === 'admin') {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: { message: 'Cannot delete admin user' }
|
|
})
|
|
}
|
|
|
|
// Delete user
|
|
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
|
|
|
logger.info(`User deleted: ${existingUser.username}`)
|
|
res.json({ success: true, message: 'User deleted successfully' })
|
|
} catch (error) {
|
|
logger.error('Error deleting user:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
export default router
|
|
|
|
// Create user account from existing employee (admin only)
|
|
router.post('/create-from-employee',
|
|
authenticate,
|
|
requirePermission('users:create'),
|
|
[
|
|
body('employeeId').notEmpty().isString(),
|
|
body('username').optional().isString().isLength({ min: 3 }),
|
|
body('role').isIn(['admin', 'superuser', 'user'])
|
|
],
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { message: 'Invalid input', details: errors.array() }
|
|
})
|
|
}
|
|
|
|
const { employeeId, username, role } = req.body
|
|
|
|
// Check employee exists
|
|
const employee = encryptedDb.getEmployee(employeeId) as any
|
|
if (!employee) {
|
|
return res.status(404).json({ success: false, error: { message: 'Employee not found' } })
|
|
}
|
|
|
|
// Check if a user is already linked to this employee
|
|
const existingByEmployee = db.prepare('SELECT id FROM users WHERE employee_id = ?').get(employeeId)
|
|
if (existingByEmployee) {
|
|
return res.status(409).json({ success: false, error: { message: 'User already exists for this employee' } })
|
|
}
|
|
|
|
// Determine email and username
|
|
const email: string | null = employee.email || null
|
|
const finalUsername = username || (email ? String(email).split('@')[0] : `${employee.first_name}.${employee.last_name}`)
|
|
|
|
// Check username uniqueness
|
|
const usernameExists = db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)
|
|
if (usernameExists) {
|
|
return res.status(409).json({ success: false, error: { message: 'Username already exists' } })
|
|
}
|
|
|
|
// Check email uniqueness if available
|
|
if (email) {
|
|
const emailHash = FieldEncryption.hash(email)
|
|
const emailExists = db.prepare('SELECT id FROM users WHERE email_hash = ?').get(emailHash)
|
|
if (emailExists) {
|
|
return res.status(409).json({ success: false, error: { message: 'Email already used by another account' } })
|
|
}
|
|
}
|
|
|
|
// Generate temp password
|
|
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
|
|
const hashedPassword = await bcrypt.hash(tempPassword, 12)
|
|
const now = new Date().toISOString()
|
|
const userId = uuidv4()
|
|
|
|
// Insert user with encrypted email
|
|
db.prepare(`
|
|
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
userId,
|
|
finalUsername,
|
|
email ? FieldEncryption.encrypt(email) : null,
|
|
email ? FieldEncryption.hash(email) : null,
|
|
hashedPassword,
|
|
role,
|
|
employeeId,
|
|
1,
|
|
now,
|
|
now
|
|
)
|
|
|
|
logger.info(`User created from employee ${employeeId}: ${finalUsername} (${role})`)
|
|
return res.status(201).json({ success: true, data: { id: userId, username: finalUsername, temporaryPassword: tempPassword } })
|
|
} catch (error) {
|
|
logger.error('Error creating user from employee:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Purge users: keep only admin and one specified email (admin only)
|
|
router.post('/purge',
|
|
authenticate,
|
|
authorize('admin'),
|
|
[
|
|
body('email').isEmail()
|
|
],
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { message: 'Invalid input', details: errors.array() }
|
|
})
|
|
}
|
|
|
|
const { email } = req.body as { email: string }
|
|
const emailHash = FieldEncryption.hash(email)
|
|
|
|
const total = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c || 0
|
|
const keep = db.prepare('SELECT id, username FROM users WHERE username = ? OR email_hash = ?').all('admin', emailHash) as any[]
|
|
|
|
const delStmt = db.prepare('DELETE FROM users WHERE username <> ? AND (email_hash IS NULL OR email_hash <> ?)')
|
|
const info = delStmt.run('admin', emailHash)
|
|
|
|
logger.warn(`User purge executed by ${req.user?.username}. Kept ${keep.length}, deleted ${info.changes}.`)
|
|
return res.json({ success: true, data: { total, kept: keep.length, deleted: info.changes } })
|
|
} catch (error) {
|
|
logger.error('Error purging users:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Send temporary password via email to user's email
|
|
router.post('/:id/send-temp-password',
|
|
authenticate,
|
|
requirePermission('users:update'),
|
|
[
|
|
body('password').notEmpty().isString().isLength({ min: 6 })
|
|
],
|
|
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: { message: 'Invalid input', details: errors.array() }
|
|
})
|
|
}
|
|
|
|
const { id } = req.params
|
|
const { password } = req.body as { password: string }
|
|
|
|
// Load user
|
|
const user = db.prepare('SELECT id, username, email, employee_id FROM users WHERE id = ?').get(id) as any
|
|
if (!user) {
|
|
return res.status(404).json({ success: false, error: { message: 'User not found' } })
|
|
}
|
|
|
|
// Decrypt email
|
|
const email = user.email ? FieldEncryption.decrypt(user.email) : null
|
|
if (!email) {
|
|
return res.status(400).json({ success: false, error: { message: 'User has no email address' } })
|
|
}
|
|
|
|
// Optional: get first name for nicer email copy
|
|
let firstName: string | undefined = undefined
|
|
if (user.employee_id) {
|
|
const emp = encryptedDb.getEmployee(user.employee_id)
|
|
if (emp && emp.first_name) firstName = emp.first_name
|
|
}
|
|
|
|
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
|
|
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
|
|
|
|
if (!emailNotificationsEnabled || !emailService.isServiceEnabled()) {
|
|
return res.status(503).json({ success: false, error: { message: 'Email service not enabled' } })
|
|
}
|
|
|
|
const sent = await emailService.sendInitialPassword(email, password, firstName)
|
|
if (!sent) {
|
|
return res.status(500).json({ success: false, error: { message: 'Failed to send email' } })
|
|
}
|
|
|
|
logger.info(`Temporary password email sent to ${email} for user ${user.username}`)
|
|
return res.json({ success: true, message: 'Temporary password email sent' })
|
|
} catch (error) {
|
|
logger.error('Error sending temporary password email:', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|