const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); const passwords = require('../utils/passwords'); const jwt = require('jsonwebtoken'); const { isMobileClient } = require('../utils/platform'); const speakeasy = require('speakeasy'); const QRCode = require('qrcode'); const crypto = require('crypto'); const db = require('../db/database'); const { JWT_SECRET, authMiddleware } = require('../middleware/auth'); const invites = require('../utils/registrationInvites'); const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions'); const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify'); var logger = require('../utils/logger'); const { requireLocalAuth, isSSOOnly } = require('../utils/policy'); // Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent) async function checkPwnedPassword(password) { try { var sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase(); var prefix = sha1.substring(0, 5); var suffix = sha1.substring(5); var resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, { headers: { 'User-Agent': 'PediatricAIScribe-PasswordCheck' } }); if (!resp.ok) return 0; var text = await resp.text(); var match = text.split('\n').find(function(line) { return line.startsWith(suffix); }); return match ? parseInt(match.split(':')[1]) : 0; } catch (e) { return 0; } // fail open — don't block registration if HIBP is down } // Public endpoint — check password before form submission router.post('/check-password', async (req, res) => { var { password } = req.body; if (!password) return res.json({ breached: false }); var count = await checkPwnedPassword(password); res.json({ breached: count > 0, count: count }); }); // ── Cookie helper ───────────────────────────────────────────── // Sets the JWT as a secure, httpOnly cookie so JS cannot read it. // maxAge is a safety net — real timeout is 24h sliding via middleware. function setAuthCookie(res, token) { var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL; res.cookie('ped_auth', token, { httpOnly: true, secure: !!isProduction, sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days absolute; middleware enforces 24h sliding idle path: '/' }); } // Issue a JWT whose absolute expiry matches the client platform: // web → 30 days (sliding 24h idle handled in middleware) // mobile → 365 days (persistent, token sits in Keychain / Keystore) function signAuthToken(userId, req) { var expiresIn = isMobileClient(req) ? '365d' : '30d'; return jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: expiresIn }); } function clearAuthCookie(res) { res.clearCookie('ped_auth', { path: '/' }); } // ============================================================ // 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 — markdown-style, Resend/Linear aesthetic // Plain, spacious, no decorative chrome. Reads like a document. // ============================================================ function emailWrapper(body) { var siteName = process.env.SITE_NAME || 'Pediatric AI Scribe'; var F = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif'; return `
|
| ${escHtml(label)} |
If the button doesn’t work, copy this link:
${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', requireLocalAuth, 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, turnstileToken, inviteCode } = req.body; if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' }); // Invite-only sits between "open" and "closed": anyone with a code, nobody // without one. Checked before the work of hashing a password, and claimed // atomically once the account exists. var inviteRequired = await invites.inviteOnly(); if (inviteRequired && !String(inviteCode || '').trim()) { return res.status(400).json({ error: 'An invitation code is required to register.' }); } if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' }); // Cloudflare Turnstile verification if (process.env.TURNSTILE_SECRET_KEY) { if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification challenge' }); var turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip }) }); var turnstileData = await turnstileRes.json(); if (!turnstileData.success) { console.error('[Auth] Turnstile verification failed:', turnstileData['error-codes']); return res.status(400).json({ error: 'Bot verification failed. Please try again.' }); } } 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 passwords.hash(password); 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; // Claimed only now, so a code is never spent on a registration that failed. // The claim is a single conditional UPDATE, so two people racing the same // code cannot both win — the loser's account is removed again rather than // left behind as a free registration. if (inviteRequired) { var claimedInvite = await invites.claim(inviteCode, userId); if (!claimedInvite) { await db.run('DELETE FROM users WHERE id = ?', [userId]); return res.status(400).json({ error: 'That invitation code is not valid. It may have expired, been revoked, or already been used.' }); } } 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, '
')}
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']); logger.audit(userId, 'register', role === 'admin' ? 'First user — auto admin' : 'standard user', req, { category: 'auth' }); notifyNewRegistration(email, name); 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 = signAuthToken(userId, req); var regSessionId = generateSessionId(); await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', [regSessionId, userId, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]); setAuthCookie(res, token); return res.json({ success: true, token: token, sessionId: regSessionId, 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('Link expired or invalid
This verification link has expired. Request a new one from the app.
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) + '. You\'re all set.
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', requireLocalAuth, async (req, res) => { try { var { email, password, totpCode } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); // No Turnstile on login. The widget could not reliably complete a // challenge inside the Capacitor WebView, which locked mobile users out. // Brute-force cover here comes from the 10-per-15-min per-IP rate limit // (server.js), the constant-time bcrypt comparison below, and TOTP 2FA. // Registration and password reset — the endpoints that actually attract // bots — are still gated. var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); // Enumeration-resistant: always run bcrypt to keep timing constant, and return // the same generic message for unknown-user, wrong-password, disabled, unverified. var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.'; if (!user) { // Server-side only — no email in the message, so operators can watch // lookup-miss rates in Grafana without leaking which addresses exist. console.warn('[Auth] login: user not found (ip=' + req.ip + ')'); await bcrypt.compare(password, DUMMY_HASH).catch(function(){}); return res.status(401).json({ error: 'Invalid credentials' }); } var valid; try { valid = await passwords.verify(password, user.password); } catch (verifyErr) { console.error('[Auth] passwords.verify threw:', verifyErr.message); return res.status(500).json({ error: 'Request failed' }); } if (!valid) { await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){}); logger.access(user.id, 'login_failed', req, false); return res.status(401).json({ error: 'Invalid credentials' }); } // Transparent migration: bcrypt → argon2id on next successful login. passwords.maybeRehash(password, user.password).then(function(newHash) { if (newHash) db.run('UPDATE users SET password = ? WHERE id = ?', [newHash, user.id]).catch(function(){}); }).catch(function(){}); 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']).catch(function(){}); logger.access(user.id, 'login_blocked', req, false); 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 totpInput = String(totpCode).trim(); var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpInput, window: 1 }); if (!verified) { // Backup-code fallback. Backup codes are 10-char alphanumeric and // each hash is bcrypt — consumed on use. var consumed = await tryConsumeBackupCode(user.id, totpInput); if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' }); await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', [user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){}); logger.audit(user.id, '2fa_backup_code_used', 'Backup code consumed at login', req, { category: 'auth' }); } } var token = signAuthToken(user.id, req); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]); logger.access(user.id, 'login', req, true); // Create session record var sessionId = generateSessionId(); await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', [sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]); // Notify user of new login (fire-and-forget) notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip); setAuthCookie(res, token); res.json({ success: true, token: token, sessionId: sessionId, 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 backup codes helpers ────────────────────────────── // 10 single-use codes, 10 chars each (alphanumeric, readable). Stored as // JSON array of bcrypt hashes in users.totp_backup_codes. function generateBackupCodes(n) { n = n || 10; var alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion var out = []; for (var i = 0; i < n; i++) { var code = ''; var buf = crypto.randomBytes(10); for (var j = 0; j < 10; j++) code += alphabet[buf[j] % alphabet.length]; // Render as XXXXX-XXXXX for readability out.push(code.slice(0, 5) + '-' + code.slice(5)); } return out; } async function hashBackupCodes(codes) { var hashes = []; for (var i = 0; i < codes.length; i++) { hashes.push(await bcrypt.hash(codes[i].replace('-', '').toUpperCase(), 10)); } return hashes; } async function tryConsumeBackupCode(userId, submitted) { if (!submitted) return false; var normalized = String(submitted).replace(/[-\s]/g, '').toUpperCase(); if (!/^[A-Z0-9]{10}$/.test(normalized)) return false; // wrong format — skip // Atomic read-modify-write: wrap the SELECT + UPDATE in a transaction // with SELECT ... FOR UPDATE to serialize concurrent consume attempts. // Without this, two parallel logins using the same code could both // pass the bcrypt compare and both succeed. The row lock funnels them // one at a time — second attempt sees the already-consumed array. var client = await db.pool.connect(); try { await client.query('BEGIN'); var rowRes = await client.query( 'SELECT totp_backup_codes FROM users WHERE id = $1 FOR UPDATE', [userId] ); var row = rowRes.rows[0]; if (!row || !row.totp_backup_codes) { await client.query('ROLLBACK'); return false; } var hashes; try { hashes = JSON.parse(row.totp_backup_codes); } catch (e) { await client.query('ROLLBACK'); return false; } if (!Array.isArray(hashes)) { await client.query('ROLLBACK'); return false; } for (var i = 0; i < hashes.length; i++) { if (await bcrypt.compare(normalized, hashes[i])) { hashes.splice(i, 1); await client.query( 'UPDATE users SET totp_backup_codes = $1 WHERE id = $2', [JSON.stringify(hashes), userId] ); await client.query('COMMIT'); return true; } } await client.query('ROLLBACK'); return false; } catch (e) { try { await client.query('ROLLBACK'); } catch (_) {} console.error('[Auth] backup-code consume error:', e.message); return false; } finally { client.release(); } } // Generate (or regenerate) backup codes. Requires current password to authorise. router.post('/2fa/backup-codes', authMiddleware, requireLocalAuth, async (req, res) => { try { var { password } = req.body; if (!password) return res.status(400).json({ error: 'Current password required' }); var user = await db.get('SELECT password, totp_enabled FROM users WHERE id = ?', [req.user.id]); if (!user || !user.totp_enabled) return res.status(400).json({ error: '2FA is not enabled' }); var valid = await passwords.verify(password, user.password); if (!valid) return res.status(401).json({ error: 'Wrong password' }); var codes = generateBackupCodes(10); var hashes = await hashBackupCodes(codes); await db.run('UPDATE users SET totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, '2fa_backup_codes_regenerated', req.ip]).catch(function(){}); logger.audit(req.user.id, '2fa_backup_codes_regenerated', 'Backup codes regenerated', req, { category: 'auth' }); // Return plaintext codes — this is the ONLY time they are visible. res.json({ success: true, codes: codes, message: 'Save these somewhere safe — they cannot be shown again.' }); } catch (err) { console.error('[Auth] 2FA backup codes error:', err.message); res.status(500).json({ error: 'Request failed' }); } }); // How many backup codes remain (for UI badge). router.get('/2fa/backup-codes/count', authMiddleware, async (req, res) => { try { var row = await db.get('SELECT totp_backup_codes FROM users WHERE id = ?', [req.user.id]); var n = 0; if (row && row.totp_backup_codes) { try { var arr = JSON.parse(row.totp_backup_codes); if (Array.isArray(arr)) n = arr.length; } catch(e){} } res.json({ success: true, remaining: n }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); // 2FA router.post('/setup-2fa', authMiddleware, requireLocalAuth, async (req, res) => { try { // SSO-only accounts never go through the local password flow, so TOTP // would sit dormant. Reject up front with a clear message. var row = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]); if (!row || !hasLocalPassword(row.password)) { return res.status(400).json({ error: 'This account uses SSO. Two-factor authentication is managed by your identity provider.' }); } 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, requireLocalAuth, async (req, res) => { try { var user = await db.get('SELECT totp_secret, totp_enabled 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' }); // First time enabling — generate backup codes and show them once. var wasFirstEnable = !user.totp_enabled; var backupCodes = null; if (wasFirstEnable) { backupCodes = generateBackupCodes(10); var hashes = await hashBackupCodes(backupCodes); await db.run('UPDATE users SET totp_enabled = true, totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]); } else { await db.run('UPDATE users SET totp_enabled = true WHERE id = ?', [req.user.id]); } res.json({ success: true, backupCodes: backupCodes }); } 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 passwords.verify(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, totp_backup_codes = 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 // // Timing-safe: response is sent after Turnstile verification, BEFORE the DB // lookup and email send. Hit and miss therefore take the same wall-clock // time (no SMTP RTT on hit, no extra latency on miss), closing the // user-enumeration oracle that previously let an attacker distinguish // registered emails by response time. router.post('/forgot-password', requireLocalAuth, async (req, res) => { try { // Cloudflare Turnstile verification (runs for every request regardless // of whether the account exists, so timing is equal) if (process.env.TURNSTILE_SECRET_KEY) { var turnstileToken = req.body.turnstileToken; if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' }); var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip }) }); var tsData = await tsRes.json(); if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' }); } var email = String(req.body.email || '').toLowerCase(); // Return the identical response immediately — do not await anything // account-specific below this point. res.json({ success: true, message: 'If account exists, reset email sent' }); // Fire-and-forget: DB lookup + token + email happen after the response // is sent, so their latency cannot be measured by the caller. async function sendResetEmailInBackground() { try { var user = await db.get('SELECT id FROM users WHERE email = ?', [email]); if (!user) return; 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(email, resetSubject, emailWrapper( `Password reset request
${escHtml(resetBody).replace(/\n/g, '
')}