pediatric-ai-scribe-v3/src/routes/auth.js
Daniel Onyejesi 82a0911d24 Dedicated CMS page, responsive email templates, security fixes in auth
- New dedicated Content Manager tab (WordPress-like layout) with:
  - Stats dashboard (published, drafts, categories, quizzes, attempts)
  - Sidebar with category tree and filter controls (status, category)
  - Table-style content list with search
  - Full-page editor with title bar, meta fields, body editor, quiz builder
- Content Manager visible only for admin and moderator roles in sidebar
- Removed CMS from admin panel (admin panel is admin-only again)
- Email templates: fully responsive with max-width, proper mobile breakpoints,
  table-based layout for Outlook/Gmail compatibility, no overflow
- Security: escape all user data in emails with escHtml(), escape APP_URL
  in verify-email HTML, replace raw err.message with generic errors in auth
- Security: email body from admin settings now HTML-escaped before rendering
2026-03-23 20:13:22 -04:00

325 lines
18 KiB
JavaScript

const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const crypto = require('crypto');
const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
// ============================================================
// HTML HELPERS
// ============================================================
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function safeAppUrl() {
return escHtml((process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''));
}
// ============================================================
// EMAIL TEMPLATES — responsive, max-width constrained
// ============================================================
function emailWrapper(body) {
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<style>
body,table,td{font-family:'Segoe UI',Roboto,Arial,sans-serif;}
@media only screen and (max-width:600px){
.email-outer{padding:16px 8px !important;}
.email-inner{width:100% !important;min-width:0 !important;}
.email-body{padding:24px 20px !important;}
.email-header{padding:20px 20px !important;}
.email-footer{padding:12px 20px 20px !important;}
.email-btn{padding:12px 24px !important;font-size:14px !important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#f3f4f6;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f3f4f6;">
<tr><td class="email-outer" style="padding:32px 16px;" align="center">
<table role="presentation" class="email-inner" cellpadding="0" cellspacing="0" border="0" style="background:white;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);max-width:520px;width:100%;">
<tr><td class="email-header" style="background:linear-gradient(135deg,#2563eb 0%,#7c3aed 100%);padding:24px 32px;">
<p style="margin:0;color:white;font-size:20px;font-weight:700;">Pediatric AI Scribe</p>
<p style="margin:4px 0 0;color:rgba(255,255,255,0.8);font-size:12px;">AI-Powered Clinical Documentation</p>
</td></tr>
<tr><td class="email-body" style="padding:32px;">${body}</td></tr>
<tr><td class="email-footer" style="padding:14px 32px 24px;border-top:1px solid #e5e7eb;">
<p style="margin:0;color:#9ca3af;font-size:11px;line-height:1.5;">This email was sent by Pediatric AI Scribe. If you did not request this, you can safely ignore it.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
}
function btnHtml(href, label) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px auto;"><tr><td align="center" style="border-radius:8px;background:linear-gradient(135deg,#2563eb,#7c3aed);">
<a class="email-btn" href="${escHtml(href)}" target="_blank" style="display:inline-block;color:white;padding:13px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;font-family:'Segoe UI',Roboto,Arial,sans-serif;">${escHtml(label)}</a>
</td></tr></table>`;
}
function linkFallback(url) {
return `<p style="color:#6b7280;font-size:12px;margin:16px 0 4px;">Button not working? Copy this link into your browser:</p>
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:11px;word-break:break-all;color:#374151;margin:0;max-width:100%;overflow-wrap:break-word;">${escHtml(url)}</p>`;
}
// Email helper — DB settings override env vars
async function getSmtpTransport() {
var nodemailer = require('nodemailer');
var host = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
if (!host) return null;
var port = parseInt(await db.getSetting('smtp.port').catch(function() { return null; }) || process.env.SMTP_PORT || '587', 10);
var user = await db.getSetting('smtp.user').catch(function() { return null; }) || process.env.SMTP_USER || '';
var pass = await db.getSetting('smtp.pass').catch(function() { return null; }) || process.env.SMTP_PASS || '';
var from = await db.getSetting('smtp.from').catch(function() { return null; }) || process.env.SMTP_FROM || user;
var secure = (await db.getSetting('smtp.secure').catch(function() { return null; }) || process.env.SMTP_SECURE || 'false') === 'true';
return { transport: nodemailer.createTransport({ host: host, port: port, secure: secure, auth: user ? { user: user, pass: pass } : undefined }), from: from };
}
async function sendEmail(to, subject, html) {
try {
var smtp = await getSmtpTransport();
if (!smtp) {
console.log('[Email] SMTP not configured. Would send to:', to);
return false;
}
await smtp.transport.sendMail({ from: smtp.from, to: to, subject: subject, html: html });
return true;
} catch (err) {
console.error('[Email] Failed:', err.message);
return false;
}
}
// ============================================================
// REGISTER (checks if registration is enabled)
// ============================================================
router.post('/register', async (req, res) => {
try {
var regEnabled = await db.getSetting('registration_enabled');
if (regEnabled === 'false') {
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
}
var { email, password, name } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) return res.status(400).json({ error: 'Email already registered' });
var hash = await bcrypt.hash(password, 12);
var verifyToken = crypto.randomBytes(32).toString('hex');
var verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
var result = await db.run(
'INSERT INTO users (email, password, name, role, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, ?, false)',
[email.toLowerCase(), hash, name, role, verifyToken, verifyExpires]
);
var userId = result.lastInsertRowid;
var verifyUrl = safeAppUrl() + '/api/auth/verify-email?token=' + verifyToken;
var verifySubject = await db.getSetting('email.verify.subject') || 'Verify your Pediatric AI Scribe account';
var verifyBody = await db.getSetting('email.verify.body') || 'Great to have you on Pediatric AI Scribe. Please verify your email address by clicking the button below.';
await sendEmail(email, verifySubject, emailWrapper(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Welcome aboard, ${escHtml(name)}!</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(verifyBody).replace(/\n/g, '<br>')}</p>
${btnHtml(verifyUrl, 'Verify My Email')}
${linkFallback(verifyUrl)}
<p style="color:#9ca3af;font-size:11px;margin:16px 0 0;">This link expires in 24 hours.</p>`
));
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
if (!smtpHost) {
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
var token = jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: '7d' });
return res.json({
success: true, token: token,
user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true },
message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).'
});
}
res.json({ success: true, needsVerification: true, message: 'Check your email for verification link.' + (role === 'admin' ? ' You are the first user and have admin privileges.' : '') });
} catch (err) {
console.error('[Auth] Register error:', err.message);
res.status(500).json({ error: 'Registration failed. Please try again.' });
}
});
// ============================================================
// VERIFY EMAIL
// ============================================================
router.get('/verify-email', async (req, res) => {
try {
var token = req.query.token;
if (!token) return res.status(400).send('Missing token');
var user = await db.get('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?', [token, Date.now()]);
if (!user) {
return res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#ef4444;">Invalid or Expired Link</h2><p><a href="' + safeAppUrl() + '">Go to app</a></p></body></html>');
}
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
await db.run('INSERT INTO audit_log (user_id, action) VALUES (?, ?)', [user.id, 'email_verified']);
res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#10b981;">Email Verified!</h2><p>Welcome, ' + escHtml(user.name) + '!</p><p style="margin-top:20px;"><a href="' + safeAppUrl() + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;">Open App</a></p></body></html>');
} catch (err) { console.error('[Auth] Verify error:', err.message); res.status(500).send('Verification failed. Please try again.'); }
});
// RESEND VERIFICATION
router.post('/resend-verification', async (req, res) => {
try {
var user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
if (!user) return res.json({ success: true, message: 'If account exists, verification email sent' });
if (user.email_verified) return res.json({ success: true, message: 'Already verified.' });
var verifyToken = crypto.randomBytes(32).toString('hex');
await db.run('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?', [verifyToken, Date.now() + 86400000, user.id]);
var verifyUrl = safeAppUrl() + '/api/auth/verify-email?token=' + verifyToken;
await sendEmail(req.body.email, 'Verify your email — Pediatric AI Scribe', emailWrapper(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Verify your email</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">Here's a fresh verification link for your account. Click below to confirm your email and get started.</p>
${btnHtml(verifyUrl, 'Verify My Email')}
${linkFallback(verifyUrl)}
<p style="color:#9ca3af;font-size:11px;margin:16px 0 0;">This link expires in 24 hours.</p>`
));
res.json({ success: true, message: 'Verification email sent' });
} catch (err) { console.error('[Auth] Resend error:', err.message); res.status(500).json({ error: 'Failed to send email' }); }
});
// ============================================================
// LOGIN (checks disabled status)
// ============================================================
router.post('/login', async (req, res) => {
try {
var { email, password, totpCode } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']);
return res.status(403).json({ error: 'Account disabled. Contact an administrator.' });
}
var valid = await bcrypt.compare(password, user.password);
if (!valid) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]);
return res.status(401).json({ error: 'Invalid credentials' });
}
if (!user.email_verified) {
return res.status(403).json({ error: 'Email not verified', needsVerification: true });
}
if (user.totp_enabled) {
if (!totpCode) return res.json({ requires2FA: true });
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 });
if (!verified) return res.status(401).json({ error: 'Invalid 2FA code' });
}
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
res.json({
success: true, token: token,
user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
});
} catch (err) { console.error('[Auth] Login error:', err.message); res.status(500).json({ error: 'Login failed' }); }
});
// 2FA
router.post('/setup-2fa', authMiddleware, async (req, res) => {
try {
var secret = speakeasy.generateSecret({ name: 'PedScribe (' + req.user.email + ')', issuer: 'Pediatric AI Scribe' });
await db.run('UPDATE users SET totp_secret = ? WHERE id = ?', [secret.base32, req.user.id]);
var qrUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({ success: true, secret: secret.base32, qrCode: qrUrl });
} catch (err) { console.error('[Auth] 2FA setup error:', err.message); res.status(500).json({ error: '2FA setup failed' }); }
});
router.post('/verify-2fa', authMiddleware, async (req, res) => {
try {
var user = await db.get('SELECT totp_secret FROM users WHERE id = ?', [req.user.id]);
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 });
if (!verified) return res.status(400).json({ error: 'Invalid code' });
await db.run('UPDATE users SET totp_enabled = true WHERE id = ?', [req.user.id]);
res.json({ success: true });
} catch (err) { console.error('[Auth] 2FA verify error:', err.message); res.status(500).json({ error: '2FA verification failed' }); }
});
router.post('/disable-2fa', authMiddleware, async (req, res) => {
try {
var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
var valid = await bcrypt.compare(req.body.password, user.password);
if (!valid) return res.status(401).json({ error: 'Wrong password' });
await db.run('UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = ?', [req.user.id]);
res.json({ success: true });
} catch (err) { console.error('[Auth] 2FA disable error:', err.message); res.status(500).json({ error: '2FA disable failed' }); }
});
// Password reset
router.post('/forgot-password', async (req, res) => {
try {
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' });
var token = crypto.randomBytes(32).toString('hex');
await db.run('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?', [token, Date.now() + 3600000, user.id]);
var resetUrl = safeAppUrl() + '/reset-password?token=' + token;
var resetSubject = await db.getSetting('email.reset.subject') || 'Reset your password — Pediatric AI Scribe';
var resetBody = await db.getSetting('email.reset.body') || 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed.';
await sendEmail(req.body.email, resetSubject, emailWrapper(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Password reset request</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(resetBody).replace(/\n/g, '<br>')}</p>
${btnHtml(resetUrl, 'Reset My Password')}
${linkFallback(resetUrl)}`
));
res.json({ success: true, message: 'If account exists, reset email sent' });
} catch (err) { console.error('[Auth] Forgot password error:', err.message); res.status(500).json({ error: 'Password reset request failed' }); }
});
router.post('/reset-password', async (req, res) => {
try {
var { token, newPassword } = req.body;
if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' });
var user = await db.get('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?', [token, Date.now()]);
if (!user) return res.status(400).json({ error: 'Invalid or expired token' });
var hash = await bcrypt.hash(newPassword, 12);
await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
res.json({ success: true });
} catch (err) { console.error('[Auth] Reset password error:', err.message); res.status(500).json({ error: 'Password reset failed' }); }
});
// Get current user
router.get('/me', authMiddleware, async (req, res) => {
try {
var user = await db.get(
'SELECT id, email, name, role, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, created_at FROM users WHERE id = ?',
[req.user.id]
);
res.json({ user: user });
} catch (err) { console.error('[Auth] Me error:', err.message); res.status(500).json({ error: 'Failed to load user' }); }
});
// Check if registration is enabled (public endpoint)
router.get('/registration-status', async (req, res) => {
try {
var enabled = await db.getSetting('registration_enabled');
res.json({ registrationEnabled: enabled !== 'false' });
} catch (err) { res.json({ registrationEnabled: true }); }
});
// Expose helpers for adminConfig test-email and email template loading
module.exports = router;
module.exports.__sendEmail = sendEmail;
module.exports.__emailWrapper = emailWrapper;
module.exports.__btnHtml = btnHtml;