Files
redlight/server/routes/admin.js
Michelle 43d94181f9
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
feat: add getBaseUrl function for consistent base URL generation across routes
feat(calendar): display local timezone in calendar view
feat(i18n): add timezone label to German and English translations
2026-03-04 09:44:02 +01:00

366 lines
13 KiB
JavaScript

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;