import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js'; import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; import { getOAuthConfig, saveOAuthConfig, deleteOAuthConfig, discoverOIDC, } from '../config/oauth.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 - (3-30 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 = await bcrypt.hash(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 = await bcrypt.hash(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 = getBaseUrl(req); 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, 'en'); } 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' }); } }); // ── OAuth / SSO Configuration (admin only) ────────────────────────────────── // GET /api/admin/oauth - Get current OAuth configuration router.get('/oauth', authenticateToken, requireAdmin, async (req, res) => { try { const config = await getOAuthConfig(); if (!config) { return res.json({ configured: false, config: null }); } // Never expose the decrypted client secret to the frontend res.json({ configured: true, config: { issuer: config.issuer, clientId: config.clientId, hasClientSecret: !!config.clientSecret, displayName: config.displayName || 'SSO', autoRegister: config.autoRegister ?? true, }, }); } catch (err) { log.admin.error(`Get OAuth config error: ${err.message}`); res.status(500).json({ error: 'Could not load OAuth configuration' }); } }); // PUT /api/admin/oauth - Save OAuth configuration router.put('/oauth', authenticateToken, requireAdmin, async (req, res) => { try { const { issuer, clientId, clientSecret, displayName, autoRegister } = req.body; if (!issuer || !clientId) { return res.status(400).json({ error: 'Issuer URL and Client ID are required' }); } // Validate issuer URL try { const parsed = new URL(issuer); if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { return res.status(400).json({ error: 'Issuer URL must use https:// (or http:// for development)' }); } } catch { return res.status(400).json({ error: 'Invalid Issuer URL' }); } // Validate display name length if (displayName && displayName.length > 50) { return res.status(400).json({ error: 'Display name must not exceed 50 characters' }); } // Check if the existing config has a secret and none is being sent (keep old one) let finalSecret = clientSecret; if (!clientSecret) { const existing = await getOAuthConfig(); if (existing?.clientSecret) { finalSecret = existing.clientSecret; } } // Attempt OIDC discovery to validate the issuer endpoint try { await discoverOIDC(issuer); } catch (discErr) { return res.status(400).json({ error: `Could not discover OIDC configuration at ${issuer}: ${discErr.message}`, }); } await saveOAuthConfig({ issuer, clientId, clientSecret: finalSecret || '', displayName: displayName || 'SSO', autoRegister: autoRegister !== false, }); log.admin.info(`OAuth configuration saved by admin (issuer: ${issuer})`); res.json({ message: 'OAuth configuration saved' }); } catch (err) { log.admin.error(`Save OAuth config error: ${err.message}`); res.status(500).json({ error: 'Could not save OAuth configuration' }); } }); // DELETE /api/admin/oauth - Remove OAuth configuration router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => { try { await deleteOAuthConfig(); log.admin.info('OAuth configuration removed by admin'); res.json({ message: 'OAuth configuration removed' }); } catch (err) { log.admin.error(`Delete OAuth config error: ${err.message}`); res.status(500).json({ error: 'Could not remove OAuth configuration' }); } }); export default router;