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, '&').replace(//g, '>').replace(/"/g, '"'); } function safeAppUrl() { return escHtml((process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '')); } // ============================================================ // EMAIL TEMPLATES — responsive, max-width constrained // ============================================================ function emailWrapper(body) { return `
`; } function btnHtml(href, label) { return `
`; } function linkFallback(url) { return `

Button not working? Copy this link into your browser:

${escHtml(url)}

`; } // 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( `

Welcome aboard, ${escHtml(name)}!

${escHtml(verifyBody).replace(/\n/g, '
')}

${btnHtml(verifyUrl, 'Verify My Email')} ${linkFallback(verifyUrl)}

This link expires in 24 hours.

` )); 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('

Invalid or Expired Link

Go to app

'); } 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('

Email Verified!

Welcome, ' + escHtml(user.name) + '!

Open App

'); } 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( `

Verify your email

Here's a fresh verification link for your account. Click below to confirm your email and get started.

${btnHtml(verifyUrl, 'Verify My Email')} ${linkFallback(verifyUrl)}

This link expires in 24 hours.

` )); 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( `

Password reset request

${escHtml(resetBody).replace(/\n/g, '
')}

${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;