From 82a0911d245ff2ca417d428b17900fd4e46d03b3 Mon Sep 17 00:00:00 2001 From: Daniel Onyejesi Date: Mon, 23 Mar 2026 20:13:22 -0400 Subject: [PATCH] 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 --- public/components/admin.html | 92 ----------------------- public/components/cms.html | 128 ++++++++++++++++++++++++++++++++ public/css/styles.css | 83 +++++++++++++++++++++ public/index.html | 5 ++ public/js/admin.js | 31 +------- public/js/auth.js | 23 +++--- public/js/learningHub.js | 116 ++++++++++++++++++++--------- src/routes/auth.js | 137 ++++++++++++++++++++--------------- 8 files changed, 388 insertions(+), 227 deletions(-) create mode 100644 public/components/cms.html diff --git a/public/components/admin.html b/public/components/admin.html index b5b6d74..d116755 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -202,98 +202,6 @@ - -
-
-

Learning Hub CMS

- -
-
- - -
-

Categories

-
-
- - -
-
- -
- - -
-
-

Content

- -
-
-
-
-
- - - -

Reset Settings

diff --git a/public/components/cms.html b/public/components/cms.html new file mode 100644 index 0000000..39ad990 --- /dev/null +++ b/public/components/cms.html @@ -0,0 +1,128 @@ +
+

Content Manager

+

Create and manage Learning Hub content, quizzes, and categories

+
+ + +
+
-Published
+
-Drafts
+
-Categories
+
-Quizzes
+
-Attempts
+
+ + +
+ + + + + +
+ + +
+
+ + +
+ +
+
+ Title + Category + Type + Status + Updated + +
+
+
Loading...
+
+
+
+ + + + +
+
diff --git a/public/css/styles.css b/public/css/styles.css index 4ef8393..822e04c 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -557,6 +557,89 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(-- /* CMS question builder */ .lh-question-block{padding:12px;border:1px solid var(--g200);border-radius:8px;margin-bottom:8px;background:var(--g50);} + +/* ============================================================ + CONTENT MANAGER (WordPress-like CMS) + ============================================================ */ + +/* Stats bar */ +.cms-stats-bar{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;} +.cms-stat{flex:1;min-width:90px;background:white;border:1px solid var(--g200);border-radius:10px;padding:12px 16px;text-align:center;} +.cms-stat-value{display:block;font-size:22px;font-weight:700;color:var(--blue);} +.cms-stat-label{font-size:11px;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;} + +/* Layout */ +.cms-layout{display:grid;grid-template-columns:220px 1fr;gap:16px;min-height:500px;} +.cms-sidebar{display:flex;flex-direction:column;gap:12px;} +.cms-sidebar-section{background:white;border:1px solid var(--g200);border-radius:10px;padding:14px;} +.cms-sidebar-section h4{margin:0 0 10px;font-size:13px;color:var(--g600);display:flex;align-items:center;gap:6px;} +.cms-main{background:white;border:1px solid var(--g200);border-radius:10px;overflow:hidden;} + +/* Category list in sidebar */ +.cms-cat-list{display:flex;flex-direction:column;gap:2px;margin-bottom:8px;} +.cms-cat-item{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-radius:6px;font-size:13px;cursor:default;} +.cms-cat-item:hover{background:var(--g50);} +.cms-cat-item .cms-cat-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.cms-cat-item .cms-cat-count{font-size:11px;color:var(--g400);margin:0 6px;} +.cms-add-cat{display:flex;gap:4px;} +.cms-btn-add{width:32px;height:32px;border:1px solid var(--g300);border-radius:6px;background:white;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--blue);font-size:12px;} +.cms-btn-add:hover{background:var(--blue-light);} + +/* Input small */ +.cms-input-sm{font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;font-family:inherit;background:white;box-sizing:border-box;} +.cms-input-sm:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-light);} + +/* Toolbar */ +.cms-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--g200);gap:12px;} +.cms-search{display:flex;align-items:center;gap:6px;border:1px solid var(--g300);border-radius:6px;padding:0 10px;background:white;flex:1;max-width:280px;} +.cms-search i{color:var(--g400);font-size:12px;} +.cms-search input{border:none;outline:none;font-size:13px;padding:6px 0;width:100%;background:transparent;} + +/* Content table */ +.cms-content-table{font-size:13px;} +.cms-table-header{display:grid;grid-template-columns:1fr 110px 80px 80px 90px 40px;gap:8px;padding:8px 16px;background:var(--g50);border-bottom:1px solid var(--g200);font-weight:600;color:var(--g500);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;} +.cms-table-body{max-height:calc(100vh - 340px);overflow-y:auto;} +.cms-table-row{display:grid;grid-template-columns:1fr 110px 80px 80px 90px 40px;gap:8px;padding:10px 16px;border-bottom:1px solid var(--g100);align-items:center;cursor:pointer;transition:background 0.1s;} +.cms-table-row:hover{background:var(--g50);} +.cms-col-title{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.cms-col-title .cms-title-sub{font-size:11px;color:var(--g400);font-weight:400;} +.cms-col-cat,.cms-col-type,.cms-col-date{color:var(--g500);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.cms-col-status .cms-badge{font-size:11px;padding:2px 8px;border-radius:10px;font-weight:500;} +.cms-badge-pub{background:var(--green-light);color:#166534;} +.cms-badge-draft{background:var(--g100);color:var(--g500);} +.cms-col-actions{text-align:center;} + +/* Editor */ +.cms-editor{padding:16px 20px;} +.cms-editor-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px;} +.cms-editor-actions{display:flex;gap:8px;align-items:center;} +.cms-title-input{width:100%;font-size:22px;font-weight:600;padding:10px 0;border:none;border-bottom:2px solid var(--g200);outline:none;font-family:inherit;color:var(--g800);margin-bottom:12px;} +.cms-title-input:focus{border-bottom-color:var(--blue);} +.cms-meta-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px;} +.cms-meta-field label{display:block;font-size:11px;font-weight:600;color:var(--g500);margin-bottom:3px;text-transform:uppercase;letter-spacing:0.3px;} +.cms-meta-field select,.cms-meta-field input{width:100%;} +.cms-label{display:block;font-size:12px;font-weight:600;color:var(--g600);margin-bottom:6px;} +.cms-body-editor{width:100%;font-size:13px;line-height:1.6;padding:14px;border:1px solid var(--g300);border-radius:8px;resize:vertical;box-sizing:border-box;font-family:'Courier New',monospace;background:var(--g50);min-height:200px;} +.cms-body-editor:focus{outline:none;border-color:var(--blue);background:white;box-shadow:0 0 0 2px var(--blue-light);} +.cms-body-section{margin-bottom:20px;} + +/* Quiz section in editor */ +.cms-quiz-section{border-top:2px solid var(--g200);padding-top:16px;} +.cms-quiz-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;} +.cms-quiz-header h3{margin:0;font-size:15px;color:var(--g700);} + +/* Mobile responsive */ +@media(max-width:768px){ + .cms-layout{grid-template-columns:1fr;} + .cms-sidebar{flex-direction:row;overflow-x:auto;gap:8px;} + .cms-sidebar-section{min-width:200px;flex-shrink:0;} + .cms-table-header,.cms-table-row{grid-template-columns:1fr 80px 70px;font-size:12px;} + .cms-col-cat,.cms-col-date,.cms-col-actions{display:none;} + .cms-meta-row{grid-template-columns:1fr;gap:8px;} + .cms-stats-bar{gap:4px;} + .cms-stat{padding:8px 10px;} + .cms-stat-value{font-size:18px;} +} .lh-option-row{display:flex;align-items:center;gap:6px;margin-bottom:4px;} @media(max-width:640px){ diff --git a/public/index.html b/public/index.html index fb707af..501e7aa 100644 --- a/public/index.html +++ b/public/index.html @@ -202,6 +202,10 @@ Learning Hub + ' + + return '
' + + '' + esc(c.name) + '' + + '' + c.content_count + '' + + '' + '
'; }).join(''); } - // Update category dropdown in editor + // Update category dropdowns (editor + filter) var sel = document.getElementById('lh-cms-edit-category'); if (sel) { sel.innerHTML = ''; @@ -440,6 +443,13 @@ sel.innerHTML += ''; }); } + var filterSel = document.getElementById('cms-filter-category'); + if (filterSel) { + filterSel.innerHTML = ''; + data.categories.forEach(function(c) { + filterSel.innerHTML += ''; + }); + } }); } @@ -487,28 +497,69 @@ container.innerHTML = '

No content yet. Click "New Content" to create.

'; return; } - container.innerHTML = data.content.map(function(item) { - var statusBadge = item.published - ? 'Published' - : 'Draft'; - var qBadge = item.question_count > 0 ? ' ' + item.question_count + 'Q' : ''; - - return '
' + - '
' + - '
' + esc(item.title) + qBadge + '
' + - '
' + (item.category_name || 'Uncategorized') + (item.subject ? ' | ' + esc(item.subject) : '') + '
' + - '
' + - statusBadge + - '' + - '
'; - }).join(''); + window._cmsContentData = data.content; + renderCmsContentList(data.content); }); } + function renderCmsContentList(items) { + var container = document.getElementById('lh-cms-content-list'); + if (!container) return; + if (!items || items.length === 0) { + container.innerHTML = '
No content yet. Click "New Content" to get started.
'; + return; + } + + container.innerHTML = items.map(function(item) { + var statusBadge = item.published + ? 'Published' + : 'Draft'; + var typeLabel = item.content_type === 'quiz' ? 'Quiz' : item.content_type === 'pearl' ? 'Pearl' : 'Article'; + var qBadge = item.question_count > 0 ? ' ' + item.question_count + 'Q' : ''; + var date = item.updated_at ? new Date(item.updated_at).toLocaleDateString() : ''; + + return '
' + + '' + esc(item.title) + qBadge + '
' + esc(item.subject || '') + '
' + + '' + esc(item.category_name || 'Uncategorized') + '' + + '' + typeLabel + '' + + '' + statusBadge + '' + + '' + date + '' + + '' + + '
'; + }).join(''); + } + + // CMS search & filter + document.addEventListener('input', function(e) { + if (e.target.id === 'cms-search') filterCmsContent(); + }); + document.addEventListener('change', function(e) { + if (e.target.id === 'cms-filter-status' || e.target.id === 'cms-filter-category') filterCmsContent(); + }); + + function filterCmsContent() { + var items = window._cmsContentData || []; + var search = (document.getElementById('cms-search') || {}).value || ''; + var statusFilter = (document.getElementById('cms-filter-status') || {}).value || 'all'; + var catFilter = (document.getElementById('cms-filter-category') || {}).value || 'all'; + + search = search.toLowerCase().trim(); + var filtered = items.filter(function(item) { + if (search && item.title.toLowerCase().indexOf(search) === -1 && (item.subject || '').toLowerCase().indexOf(search) === -1) return false; + if (statusFilter === 'published' && !item.published) return false; + if (statusFilter === 'draft' && item.published) return false; + if (catFilter !== 'all' && String(item.category_id) !== catFilter) return false; + return true; + }); + renderCmsContentList(filtered); + } + function openEditor(contentId) { var editor = document.getElementById('lh-cms-editor'); + var listView = document.getElementById('cms-list-view'); if (!editor) return; editor.classList.remove('hidden'); + if (listView) listView.classList.add('hidden'); // Reset document.getElementById('lh-cms-edit-id').value = ''; @@ -519,13 +570,9 @@ document.getElementById('lh-cms-edit-type').value = 'article'; document.getElementById('lh-cms-edit-published').value = 'false'; document.getElementById('lh-cms-questions').innerHTML = ''; - document.getElementById('lh-cms-editor-title').innerHTML = ' New Content'; document.getElementById('btn-lh-delete-content').classList.add('hidden'); - if (!contentId) { - editor.scrollIntoView({ behavior: 'smooth' }); - return; - } + if (!contentId) return; // Load existing content fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() }) @@ -540,7 +587,6 @@ document.getElementById('lh-cms-edit-category').value = c.category_id || ''; document.getElementById('lh-cms-edit-type').value = c.content_type || 'article'; document.getElementById('lh-cms-edit-published').value = c.published ? 'true' : 'false'; - document.getElementById('lh-cms-editor-title').innerHTML = ' Edit: ' + esc(c.title); document.getElementById('btn-lh-delete-content').classList.remove('hidden'); // Load questions @@ -556,7 +602,9 @@ function closeEditor() { var editor = document.getElementById('lh-cms-editor'); + var listView = document.getElementById('cms-list-view'); if (editor) editor.classList.add('hidden'); + if (listView) listView.classList.remove('hidden'); } function saveContent() { diff --git a/src/routes/auth.js b/src/routes/auth.js index cb9dc83..8e27639 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -9,21 +9,45 @@ const db = require('../db/database'); const { JWT_SECRET, authMiddleware } = require('../middleware/auth'); // ============================================================ -// EMAIL TEMPLATES +// 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 ` - - -
- -
-

🩺 Pediatric AI Scribe

-

AI-Powered Clinical Documentation

+ return ` + + + + + @@ -31,14 +55,20 @@ function emailWrapper(body) { `; } -function btnStyle() { - return 'display:inline-block;background:linear-gradient(135deg,#2563eb,#7c3aed);color:white;padding:13px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;'; +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'); - // DB settings override env vars 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); @@ -69,7 +99,6 @@ async function sendEmail(to, subject, html) { // ============================================================ router.post('/register', async (req, res) => { try { - // Check if registration is enabled var regEnabled = await db.getSetting('registration_enabled'); if (regEnabled === 'false') { return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' }); @@ -86,7 +115,6 @@ router.post('/register', async (req, res) => { var verifyToken = crypto.randomBytes(32).toString('hex'); var verifyExpires = Date.now() + 24 * 60 * 60 * 1000; - // First user becomes admin automatically var userCount = await db.get('SELECT COUNT(*) as count FROM users', []); var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user'; @@ -96,25 +124,21 @@ router.post('/register', async (req, res) => { ); var userId = result.lastInsertRowid; - - var verifyUrl = (process.env.APP_URL || 'http://localhost:3000') + '/api/auth/verify-email?token=' + verifyToken; + 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, ${name}!

-

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

-

- Verify My Email -

-

Button not working? Copy and paste this link into your browser:

-

${verifyUrl}

-

This link expires in 24 hours.

` + `

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']); - // Auto-verify if no SMTP (check DB setting first, then env) 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]); @@ -128,8 +152,8 @@ router.post('/register', async (req, res) => { 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('Register error:', err); - res.status(500).json({ error: err.message }); + console.error('[Auth] Register error:', err.message); + res.status(500).json({ error: 'Registration failed. Please try again.' }); } }); @@ -142,13 +166,12 @@ router.get('/verify-email', async (req, res) => { 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

'); + 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']); - var safeName = user.name.replace(/&/g, '&').replace(//g, '>'); - res.send('

✅ Email Verified!

Welcome, ' + safeName + '!

Open App

'); - } catch (err) { res.status(500).send('Error: ' + err.message); } + 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 @@ -159,19 +182,16 @@ router.post('/resend-verification', async (req, res) => { 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 = (process.env.APP_URL || 'http://localhost:3000') + '/api/auth/verify-email?token=' + verifyToken; + 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.

-

- Verify My Email -

-

Button not working? Copy and paste this link into your browser:

-

${verifyUrl}

-

This link expires in 24 hours.

` + `

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) { res.status(500).json({ error: err.message }); } + } catch (err) { console.error('[Auth] Resend error:', err.message); res.status(500).json({ error: 'Failed to send email' }); } }); // ============================================================ @@ -214,7 +234,7 @@ router.post('/login', async (req, res) => { 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) { res.status(500).json({ error: err.message }); } + } catch (err) { console.error('[Auth] Login error:', err.message); res.status(500).json({ error: 'Login failed' }); } }); // 2FA @@ -224,7 +244,7 @@ router.post('/setup-2fa', authMiddleware, async (req, res) => { 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) { res.status(500).json({ error: err.message }); } + } 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) => { @@ -234,7 +254,7 @@ router.post('/verify-2fa', authMiddleware, async (req, res) => { 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) { res.status(500).json({ error: err.message }); } + } 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) => { @@ -244,7 +264,7 @@ router.post('/disable-2fa', authMiddleware, async (req, res) => { 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) { res.status(500).json({ error: err.message }); } + } catch (err) { console.error('[Auth] 2FA disable error:', err.message); res.status(500).json({ error: '2FA disable failed' }); } }); // Password reset @@ -254,20 +274,17 @@ router.post('/forgot-password', async (req, res) => { 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 = (process.env.APP_URL || 'http://localhost:3000') + '/reset-password?token=' + token; + 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

-

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

-

- Reset My Password -

-

Button not working? Copy and paste this link into your browser:

-

${resetUrl}

` + `

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) { res.status(500).json({ error: err.message }); } + } 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) => { @@ -279,7 +296,7 @@ router.post('/reset-password', async (req, res) => { 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) { res.status(500).json({ error: err.message }); } + } catch (err) { console.error('[Auth] Reset password error:', err.message); res.status(500).json({ error: 'Password reset failed' }); } }); // Get current user @@ -290,7 +307,7 @@ router.get('/me', authMiddleware, async (req, res) => { [req.user.id] ); res.json({ user: user }); - } catch (err) { res.status(500).json({ error: err.message }); } + } 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) @@ -305,4 +322,4 @@ router.get('/registration-status', async (req, res) => { module.exports = router; module.exports.__sendEmail = sendEmail; module.exports.__emailWrapper = emailWrapper; -module.exports.__btnStyle = btnStyle; +module.exports.__btnHtml = btnHtml;