Two things, both about not making someone redo work. One key per provider. There was a single websearch.api_key shared by all of them, so trying a different provider meant pasting a new key over the working one and pasting the old one back to return — and the keys are not interchangeable, so a wrong pairing fails as an authentication error that reads like a dead provider. Each now has its own slot. The old shared key is still read as a fallback: whatever was configured before this is the right key for whichever provider was selected at the time. The sign-in code email went out raw, while every other message this app sends goes through emailWrapper — so the one mail a person receives while locked out was the one that looked least like it came from us. It now uses the same wrapper, and the body is built around the thing the reader actually needs: the code, alone, large, monospaced so a 0 cannot be read as an O, in a box of its own. It also names the address it signs into. A code arriving at a shared mailbox, or to someone with two accounts, is otherwise a number with no indication of what it opens — and that line is the one thing that lets a person notice a sign-in they did not start. The address is escaped; it is the only part of that mail that did not come from us. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
815 lines
43 KiB
JavaScript
815 lines
43 KiB
JavaScript
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 loginCodes = require('../utils/loginCodes');
|
|
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, '>').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 `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>${escHtml(siteName)}</title>
|
|
<style>
|
|
body,table,td,p,a{font-family:${F};}
|
|
@media only screen and (max-width:600px){
|
|
.outer{padding:24px 16px !important;}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#ffffff;-webkit-text-size-adjust:100%;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
<tr><td class="outer" align="center" style="padding:48px 24px;">
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;width:100%;">
|
|
|
|
<!-- Wordmark -->
|
|
<tr><td style="padding-bottom:32px;border-bottom:1px solid #e5e7eb;">
|
|
<p style="margin:0;font-size:14px;font-weight:600;color:#111827;letter-spacing:-0.1px;">${escHtml(siteName)}</p>
|
|
</td></tr>
|
|
|
|
<!-- Body -->
|
|
<tr><td style="padding:32px 0;">
|
|
${body}
|
|
</td></tr>
|
|
|
|
<!-- Footer -->
|
|
<tr><td style="padding-top:24px;border-top:1px solid #e5e7eb;">
|
|
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.7;">
|
|
${escHtml(siteName)} — AI-powered clinical documentation<br>
|
|
If you didn’t 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 0;">
|
|
<tr><td style="border-radius:5px;background:#111827;">
|
|
<a href="${escHtml(href)}" target="_blank"
|
|
style="display:inline-block;color:#ffffff;padding:11px 22px;border-radius:5px;
|
|
text-decoration:none;font-weight:500;font-size:14px;line-height:1;
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">${escHtml(label)}</a>
|
|
</td></tr>
|
|
</table>`;
|
|
}
|
|
|
|
function linkFallback(url) {
|
|
return `<p style="margin:16px 0 4px;font-size:13px;color:#6b7280;line-height:1.5;">If the button doesn’t work, copy this link:</p>
|
|
<p style="margin:0;padding:10px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;
|
|
font-size:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;
|
|
word-break:break-all;color:#374151;line-height:1.6;">${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', 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(
|
|
`<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']);
|
|
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('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Link Expired</title></head><body style="margin:0;padding:40px 24px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif;background:#fafafa;text-align:center;"><p style="font-size:15px;font-weight:600;color:#111827;margin:0 0 8px;">Link expired or invalid</p><p style="color:#6b7280;font-size:14px;margin:0 0 24px;">This verification link has expired. Request a new one from the app.</p><a href="' + safeAppUrl() + '" style="display:inline-block;background:#111827;color:#fff;padding:10px 22px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">Go to app</a></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('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Email Verified</title></head><body style="margin:0;padding:40px 24px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif;background:#fafafa;text-align:center;"><p style="font-size:15px;font-weight:600;color:#111827;margin:0 0 8px;">Email verified</p><p style="color:#6b7280;font-size:14px;margin:0 0 24px;">Welcome, ' + escHtml(user.name) + '. You\'re all set.</p><a href="' + safeAppUrl() + '" style="display:inline-block;background:#111827;color:#fff;padding:10px 22px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">Open app</a></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' }); }
|
|
});
|
|
|
|
// ============================================================
|
|
// SIGN-IN CODE
|
|
// ============================================================
|
|
// Signing in with a code emailed to you, offered alongside the password rather
|
|
// than instead of it: the code depends on mail being delivered and the password
|
|
// does not, so neither can be the only way in.
|
|
//
|
|
// Requesting one answers identically whether or not the address exists. A
|
|
// sign-in screen that says "no such account" is a way of finding out who has
|
|
// one, and that is worth more to an attacker than the code is.
|
|
router.post('/login-code/request', requireLocalAuth, async (req, res) => {
|
|
var email = String((req.body && req.body.email) || '').toLowerCase().trim();
|
|
// Said the same way on every path below, including the ones that do nothing.
|
|
var GENERIC = { success: true, message: 'If that account exists, a code is on its way.' };
|
|
try {
|
|
if (!email || email.length > 320) return res.json(GENERIC);
|
|
|
|
var user = await db.get('SELECT id, email, name, disabled FROM users WHERE email = ?', [email]);
|
|
if (!user || user.disabled) {
|
|
console.warn('[Auth] login-code: no deliverable account (ip=' + req.ip + ')');
|
|
return res.json(GENERIC);
|
|
}
|
|
|
|
var code = await loginCodes.issue(db, user.id);
|
|
loginCodes.sweep(db);
|
|
// Fire and forget: whether the mail went is not something the response may
|
|
// reveal, and the password is still there if it did not.
|
|
// Through the same wrapper as the rest of the mail, so a sign-in code looks
|
|
// like it came from the same place as the verification and reset emails.
|
|
// It said 'PedAI' while every other message says SITE_NAME.
|
|
var codeSiteName = process.env.SITE_NAME || 'Pediatric AI Scribe';
|
|
sendEmail(user.email, 'Your sign-in code for ' + codeSiteName,
|
|
emailWrapper(loginCodes.emailBody(code, codeSiteName, user.email)))
|
|
.catch(function (err) { console.warn('[Auth] login-code send failed:', err.message); });
|
|
|
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)',
|
|
[user.id, 'login_code_requested', req.ip]).catch(function () {});
|
|
res.json(GENERIC);
|
|
} catch (err) {
|
|
console.error('[Auth] login-code request error:', err.message);
|
|
res.json(GENERIC);
|
|
}
|
|
});
|
|
|
|
router.post('/login-code/verify', requireLocalAuth, async (req, res) => {
|
|
try {
|
|
var email = String((req.body && req.body.email) || '').toLowerCase().trim();
|
|
var code = String((req.body && req.body.code) || '');
|
|
var totpCode = (req.body && req.body.totpCode) || '';
|
|
// One message for every failure. Which of them it was is not the caller's
|
|
// business, and saying would turn this into an account oracle.
|
|
var REFUSED = { error: 'That code is not valid. Request a new one, or sign in with your password.' };
|
|
if (!email || !code) return res.status(400).json(REFUSED);
|
|
|
|
var user = await db.get('SELECT * FROM users WHERE email = ?', [email]);
|
|
if (!user || user.disabled) {
|
|
console.warn('[Auth] login-code verify: no deliverable account (ip=' + req.ip + ')');
|
|
return res.status(401).json(REFUSED);
|
|
}
|
|
|
|
var ok = await loginCodes.consume(db, user.id, code);
|
|
if (!ok) {
|
|
logger.access(user.id, 'login_code', req, false);
|
|
return res.status(401).json(REFUSED);
|
|
}
|
|
|
|
// A code proves you can read the mailbox, which is one factor. An account
|
|
// that asked for a second still wants it.
|
|
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) {
|
|
var consumed = await tryConsumeBackupCode(user.id, totpInput);
|
|
if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' });
|
|
}
|
|
}
|
|
|
|
var token = signAuthToken(user.id, req);
|
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)',
|
|
[user.id, 'login_code', req.ip]);
|
|
logger.access(user.id, 'login_code', req, true);
|
|
|
|
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'])]);
|
|
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-code verify error:', err.message);
|
|
res.status(500).json({ error: 'Sign-in failed' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// 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(
|
|
`<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)}`
|
|
));
|
|
} catch (bgErr) {
|
|
console.error('[Auth] forgot-password background send failed:', bgErr.message);
|
|
}
|
|
}
|
|
sendResetEmailInBackground();
|
|
} catch (err) { console.error('[Auth] Forgot password error:', err.message); res.status(500).json({ error: 'Password reset request failed' }); }
|
|
});
|
|
|
|
router.post('/reset-password', requireLocalAuth, 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 pwnedCount = await checkPwnedPassword(newPassword);
|
|
var hash = await passwords.hash(newPassword);
|
|
await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
|
|
// Force logout — destroy all sessions for this user
|
|
try { await db.run('DELETE FROM user_sessions WHERE user_id = ?', [user.id]); } catch (e) { /* best effort */ }
|
|
var resp = { success: true };
|
|
if (pwnedCount > 0) resp.passwordWarning = 'This password has appeared in ' + pwnedCount.toLocaleString() + ' data breaches. Consider changing it to something unique.';
|
|
res.json(resp);
|
|
} catch (err) { console.error('[Auth] Reset password error:', err.message); res.status(500).json({ error: 'Password reset failed' }); }
|
|
});
|
|
|
|
// Logout — destroy session and clear cookie
|
|
router.post('/logout', async function(req, res) {
|
|
try {
|
|
var token = null;
|
|
var authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) token = authHeader.substring(7) || null;
|
|
if (!token && req.cookies && req.cookies.ped_auth) token = req.cookies.ped_auth;
|
|
if (token) {
|
|
await db.run('DELETE FROM user_sessions WHERE token_hash = ?', [hashToken(token)]);
|
|
}
|
|
} catch (e) { /* best effort */ }
|
|
clearAuthCookie(res);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Returns true if the user has a real password hash and can change it.
|
|
// SSO-auto-created users have a random hex blob in `password` — changing
|
|
// it is meaningless because they never authenticate locally.
|
|
function hasLocalPassword(hash) {
|
|
return !!(hash && (/^\$2[aby]\$/.test(hash) || hash.indexOf('$argon2') === 0));
|
|
}
|
|
|
|
// Change password (requires current password)
|
|
router.post('/change-password', authMiddleware, requireLocalAuth, async (req, res) => {
|
|
try {
|
|
var { currentPassword, newPassword } = req.body;
|
|
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' });
|
|
if (newPassword.length < 8) return res.status(400).json({ error: 'New password must be 8+ characters' });
|
|
|
|
var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
|
|
if (!hasLocalPassword(user.password)) {
|
|
return res.status(400).json({ error: 'This account uses SSO. Password changes are managed by your identity provider.' });
|
|
}
|
|
var valid = await passwords.verify(currentPassword, user.password);
|
|
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
|
|
|
|
var pwnedCount = await checkPwnedPassword(newPassword);
|
|
var hash = await passwords.hash(newPassword);
|
|
await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, req.user.id]);
|
|
|
|
// Destroy all OTHER sessions (keep current one)
|
|
if (req.sessionId) {
|
|
try { await db.run('DELETE FROM user_sessions WHERE user_id = ? AND id != ?', [req.user.id, req.sessionId]); } catch (e) { /* best effort */ }
|
|
}
|
|
|
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]);
|
|
logger.audit(req.user.id, 'password_changed', 'Password changed', req, { category: 'auth' });
|
|
notifyPasswordChanged(req.user.id);
|
|
|
|
var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' };
|
|
if (pwnedCount > 0) resp.passwordWarning = 'This password has appeared in ' + pwnedCount.toLocaleString() + ' data breaches. Consider choosing a different one.';
|
|
res.json(resp);
|
|
} catch (err) { console.error('[Auth] Change password error:', err.message); res.status(500).json({ error: 'Password change 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, oidc_sub, password, created_at FROM users WHERE id = ?',
|
|
[req.user.id]
|
|
);
|
|
// canLocalAuth: true if the user has a real password hash (can log in
|
|
// locally, so password change / 2FA are meaningful). SSO-auto-created
|
|
// users have a random hex blob in `password` which can't be verified
|
|
// — hide those UIs for them.
|
|
var canLocalAuth = !!(user && user.password && (/^\$2[aby]\$/.test(user.password) || user.password.indexOf('$argon2') === 0));
|
|
// Don't leak the password hash in the response.
|
|
if (user) delete user.password;
|
|
if (user) user.canLocalAuth = canLocalAuth && !await isSSOOnly();
|
|
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' && !await isSSOOnly(),
|
|
inviteOnly: await invites.inviteOnly()
|
|
});
|
|
} catch (err) { res.json({ registrationEnabled: false }); }
|
|
});
|
|
|
|
// 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;
|