Files
redlight/server/routes/admin.js
Michelle df4666bb63
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m24s
Build & Push Docker Image / build (release) Successful in 6m25s
feat(invite-system): implement user invite functionality with registration mode control
2026-03-01 12:53:45 +01:00

264 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
const router = Router();
// POST /api/admin/users - Create user (admin)
router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
try {
const { name, display_name, email, password, role } = req.body;
if (!name || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
}
// L4: display_name length limit
if (display_name.length > 100) {
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
}
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
}
// M9: email format validation
if (!EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
const validRole = ['user', 'admin'].includes(role) ? role : 'user';
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'Email is already in use' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [name]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
const hash = bcrypt.hashSync(password, 12);
const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
[name, display_name, email.toLowerCase(), hash, validRole]
);
const user = await db.get('SELECT id, name, display_name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ user });
} catch (err) {
log.admin.error(`Create user error: ${err.message}`);
res.status(500).json({ error: 'User could not be created' });
}
});
// GET /api/admin/users - List all users
router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const users = await db.all(`
SELECT id, name, display_name, email, role, language, theme, avatar_color, avatar_image, created_at,
(SELECT COUNT(*) FROM rooms WHERE rooms.user_id = users.id) as room_count
FROM users
ORDER BY created_at DESC
`);
res.json({ users });
} catch (err) {
log.admin.error(`List users error: ${err.message}`);
res.status(500).json({ error: 'Users could not be loaded' });
}
});
// PUT /api/admin/users/:id/role - Update user role
router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res) => {
try {
const { role } = req.body;
if (!['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
const db = getDb();
// Prevent demoting last admin
if (role === 'user') {
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
const currentUser = await db.get('SELECT role FROM users WHERE id = ?', [req.params.id]);
if (currentUser?.role === 'admin' && adminCount.count <= 1) {
return res.status(400).json({ error: 'The last admin cannot be demoted' });
}
}
await db.run('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [role, req.params.id]);
const updated = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [req.params.id]);
// S7: verify user actually exists
if (!updated) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user: updated });
} catch (err) {
log.admin.error(`Update role error: ${err.message}`);
res.status(500).json({ error: 'Role could not be updated' });
}
});
// DELETE /api/admin/users/:id - Delete user
router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
if (parseInt(req.params.id) === req.user.id) {
return res.status(400).json({ error: 'You cannot delete yourself' });
}
const user = await db.get('SELECT id, role FROM users WHERE id = ?', [req.params.id]);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Check if it's the last admin
if (user.role === 'admin') {
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
if (adminCount.count <= 1) {
return res.status(400).json({ error: 'The last admin cannot be deleted' });
}
}
await db.run('DELETE FROM users WHERE id = ?', [req.params.id]);
res.json({ message: 'User deleted' });
} catch (err) {
log.admin.error(`Delete user error: ${err.message}`);
res.status(500).json({ error: 'User could not be deleted' });
}
});
// PUT /api/admin/users/:id/password - Reset user password (admin)
router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, res) => {
try {
const { newPassword } = req.body;
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
}
const db = getDb();
const hash = bcrypt.hashSync(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
res.json({ message: 'Password reset' });
} catch (err) {
log.admin.error(`Reset password error: ${err.message}`);
res.status(500).json({ error: 'Password could not be reset' });
}
});
// ── User Invite System ─────────────────────────────────────────────────────
// POST /api/admin/invites - Create and send an invite
router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const { email } = req.body;
if (!email || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email address is required' });
}
const db = getDb();
// Check if user with this email already exists
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'A user with this email already exists' });
}
// Check if there's already a pending invite for this email
const existingInvite = await db.get(
'SELECT id FROM user_invites WHERE email = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[email.toLowerCase()]
);
if (existingInvite) {
return res.status(409).json({ error: 'There is already a pending invite for this email' });
}
const token = uuidv4();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
await db.run(
'INSERT INTO user_invites (token, email, created_by, expires_at) VALUES (?, ?, ?, ?)',
[token, email.toLowerCase(), req.user.id, expiresAt]
);
// Send invite email if SMTP is configured
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load app name
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
const appName = brandingSetting?.value || 'Redlight';
if (isMailerConfigured()) {
try {
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName);
} catch (mailErr) {
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
}
}
res.status(201).json({ invite: { token, email: email.toLowerCase(), expiresAt, inviteUrl } });
} catch (err) {
log.admin.error(`Create invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be created' });
}
});
// GET /api/admin/invites - List all invites
router.get('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invites = await db.all(`
SELECT ui.id, ui.token, ui.email, ui.expires_at, ui.created_at, ui.used_at,
creator.name as created_by_name,
used_user.name as used_by_name
FROM user_invites ui
LEFT JOIN users creator ON creator.id = ui.created_by
LEFT JOIN users used_user ON used_user.id = ui.used_by
ORDER BY ui.created_at DESC
`);
res.json({ invites });
} catch (err) {
log.admin.error(`List invites error: ${err.message}`);
res.status(500).json({ error: 'Invites could not be loaded' });
}
});
// DELETE /api/admin/invites/:id - Delete an invite
router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invite = await db.get('SELECT id FROM user_invites WHERE id = ?', [req.params.id]);
if (!invite) {
return res.status(404).json({ error: 'Invite not found' });
}
await db.run('DELETE FROM user_invites WHERE id = ?', [req.params.id]);
res.json({ message: 'Invite deleted' });
} catch (err) {
log.admin.error(`Delete invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be deleted' });
}
});
export default router;