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
This commit is contained in:
Daniel Onyejesi 2026-03-23 20:13:22 -04:00
parent 0327afe94c
commit 82a0911d24
8 changed files with 388 additions and 227 deletions

View file

@ -202,98 +202,6 @@
</div>
</div>
<!-- ── CMS: Learning Hub ──────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-graduation-cap"></i> Learning Hub CMS</h3>
<span id="lh-cms-stats" style="font-size:12px;color:var(--g500);"></span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:16px;">
<!-- Categories management -->
<div>
<h4 style="margin:0 0 8px;font-size:14px;"><i class="fas fa-folder"></i> Categories</h4>
<div id="lh-cms-categories" style="margin-bottom:8px;"></div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input type="text" id="lh-cms-cat-name" placeholder="New category name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;">
<button id="btn-lh-add-cat" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Category</button>
</div>
</div>
<hr style="border:none;border-top:1px solid var(--g200);margin:0;">
<!-- Content management -->
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h4 style="margin:0;font-size:14px;"><i class="fas fa-file-alt"></i> Content</h4>
<button id="btn-lh-new-content" class="btn-sm btn-primary"><i class="fas fa-plus"></i> New Content</button>
</div>
<div id="lh-cms-content-list" style="max-height:400px;overflow-y:auto;"></div>
</div>
</div>
</div>
<!-- Learning Hub Content Editor (hidden by default) -->
<div id="lh-cms-editor" class="card hidden">
<div class="card-header">
<h3 id="lh-cms-editor-title"><i class="fas fa-edit"></i> New Content</h3>
<button id="btn-lh-close-editor" class="btn-sm btn-ghost"><i class="fas fa-times"></i> Close</button>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
<input type="hidden" id="lh-cms-edit-id" value="">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Title</label>
<input type="text" id="lh-cms-edit-title" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Content title">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Subject</label>
<input type="text" id="lh-cms-edit-subject" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="e.g., Development, Cardiology">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Category</label>
<select id="lh-cms-edit-category" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;">
<option value="">Uncategorized</option>
</select>
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Type</label>
<select id="lh-cms-edit-type" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;">
<option value="article">Article</option>
<option value="quiz">Quiz Only</option>
<option value="pearl">Clinical Pearl</option>
</select>
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Body (supports basic HTML)</label>
<textarea id="lh-cms-edit-body" rows="10" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;font-family:inherit;" placeholder="Content body..."></textarea>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;">Published:</label>
<select id="lh-cms-edit-published" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
<option value="false">Draft</option>
<option value="true">Published</option>
</select>
</div>
<!-- Quiz Questions Builder -->
<hr style="border:none;border-top:1px solid var(--g200);margin:0;">
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h4 style="margin:0;font-size:14px;"><i class="fas fa-clipboard-question"></i> Quiz Questions</h4>
<button id="btn-lh-add-question" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add Question</button>
</div>
<div id="lh-cms-questions"></div>
</div>
<div style="display:flex;gap:8px;">
<button id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save Content</button>
<button id="btn-lh-delete-content" class="btn-sm hidden" style="background:var(--red-light);color:var(--red);border:none;border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer;"><i class="fas fa-trash"></i> Delete</button>
</div>
</div>
</div>
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
<div class="card" style="border:1px solid var(--red-light);">
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>

128
public/components/cms.html Normal file
View file

@ -0,0 +1,128 @@
<div class="module-header">
<h2><i class="fas fa-pen-to-square"></i> Content Manager</h2>
<p>Create and manage Learning Hub content, quizzes, and categories</p>
</div>
<!-- Stats bar -->
<div class="cms-stats-bar" id="lh-cms-stats-bar">
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-published">-</span><span class="cms-stat-label">Published</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-drafts">-</span><span class="cms-stat-label">Drafts</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-categories">-</span><span class="cms-stat-label">Categories</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-quizzes">-</span><span class="cms-stat-label">Quizzes</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-attempts">-</span><span class="cms-stat-label">Attempts</span></div>
</div>
<!-- CMS Layout: sidebar + main -->
<div class="cms-layout">
<!-- Sidebar: Categories + filters -->
<aside class="cms-sidebar">
<div class="cms-sidebar-section">
<h4><i class="fas fa-folder-tree"></i> Categories</h4>
<div id="lh-cms-categories" class="cms-cat-list"></div>
<div class="cms-add-cat">
<input type="text" id="lh-cms-cat-name" placeholder="New category..." class="cms-input-sm">
<button id="btn-lh-add-cat" class="cms-btn-add" title="Add category"><i class="fas fa-plus"></i></button>
</div>
</div>
<div class="cms-sidebar-section">
<h4><i class="fas fa-filter"></i> Filter</h4>
<select id="cms-filter-status" class="cms-input-sm" style="width:100%;margin-bottom:6px;">
<option value="all">All Status</option>
<option value="published">Published</option>
<option value="draft">Drafts</option>
</select>
<select id="cms-filter-category" class="cms-input-sm" style="width:100%;">
<option value="all">All Categories</option>
</select>
</div>
</aside>
<!-- Main: Content list / Editor -->
<div class="cms-main">
<!-- Content list view -->
<div id="cms-list-view">
<div class="cms-toolbar">
<button id="btn-lh-new-content" class="btn-sm btn-primary"><i class="fas fa-plus"></i> New Content</button>
<div class="cms-search">
<i class="fas fa-search"></i>
<input type="text" id="cms-search" placeholder="Search content..." class="cms-input-sm">
</div>
</div>
<div class="cms-content-table">
<div class="cms-table-header">
<span class="cms-col-title">Title</span>
<span class="cms-col-cat">Category</span>
<span class="cms-col-type">Type</span>
<span class="cms-col-status">Status</span>
<span class="cms-col-date">Updated</span>
<span class="cms-col-actions"></span>
</div>
<div id="lh-cms-content-list" class="cms-table-body">
<div style="text-align:center;padding:40px;color:var(--g400);">Loading...</div>
</div>
</div>
</div>
<!-- Editor view (hidden until edit/new) -->
<div id="lh-cms-editor" class="cms-editor hidden">
<div class="cms-editor-header">
<button id="btn-lh-close-editor" class="btn-sm btn-ghost"><i class="fas fa-arrow-left"></i> Back to List</button>
<div class="cms-editor-actions">
<button id="btn-lh-delete-content" class="btn-sm hidden" style="background:var(--red-light);color:var(--red);"><i class="fas fa-trash"></i> Delete</button>
<select id="lh-cms-edit-published" class="cms-input-sm">
<option value="false">Draft</option>
<option value="true">Published</option>
</select>
<button id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save</button>
</div>
</div>
<input type="hidden" id="lh-cms-edit-id" value="">
<!-- Title -->
<input type="text" id="lh-cms-edit-title" class="cms-title-input" placeholder="Enter title...">
<!-- Meta row -->
<div class="cms-meta-row">
<div class="cms-meta-field">
<label>Category</label>
<select id="lh-cms-edit-category" class="cms-input-sm">
<option value="">Uncategorized</option>
</select>
</div>
<div class="cms-meta-field">
<label>Subject</label>
<input type="text" id="lh-cms-edit-subject" class="cms-input-sm" placeholder="e.g., Development">
</div>
<div class="cms-meta-field">
<label>Type</label>
<select id="lh-cms-edit-type" class="cms-input-sm">
<option value="article">Article</option>
<option value="quiz">Quiz Only</option>
<option value="pearl">Clinical Pearl</option>
</select>
</div>
</div>
<!-- Body editor -->
<div class="cms-body-section">
<label class="cms-label">Body <span style="color:var(--g400);font-weight:400;">(supports basic HTML: bold, lists, tables, links)</span></label>
<textarea id="lh-cms-edit-body" class="cms-body-editor" rows="16" placeholder="Write your content here...&#10;&#10;You can use HTML tags like <b>, <ul>, <ol>, <table>, <blockquote>, <a>, <h2>, <h3>, <code>, <p>..."></textarea>
</div>
<!-- Quiz builder -->
<div class="cms-quiz-section">
<div class="cms-quiz-header">
<h3><i class="fas fa-clipboard-question"></i> Quiz Questions</h3>
<button id="btn-lh-add-question" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add Question</button>
</div>
<div id="lh-cms-questions" class="cms-questions-list"></div>
</div>
</div>
</div>
</div>

View file

@ -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){

View file

@ -202,6 +202,10 @@
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
</button>
<button class="tab-btn hidden" data-tab="cms" id="cms-tab-btn">
<i class="fas fa-pen-to-square"></i>
<span>Content Manager</span>
</button>
<span class="sidebar-section-label">Account</span>
<button class="tab-btn" data-tab="settings">
<i class="fas fa-cog"></i>
@ -236,6 +240,7 @@
<section id="vaxschedule-tab" class="tab-content" data-component="vaxschedule"></section>
<section id="catchup-tab" class="tab-content" data-component="catchup"></section>
<section id="learning-tab" class="tab-content" data-component="learning"></section>
<section id="cms-tab" class="tab-content" data-component="cms"></section>
<section id="admin-tab" class="tab-content" data-component="admin"></section>
<section id="wellvisit-tab" class="tab-content" data-component="wellvisit"></section>
<section id="sickvisit-tab" class="tab-content" data-component="sickvisit"></section>

View file

@ -25,35 +25,8 @@
});
function loadAdmin() {
var isModerator = window._userRole === 'moderator';
// Moderators only see Learning Hub CMS; hide other admin sections
var adminTab = document.getElementById('admin-tab');
if (adminTab) {
adminTab.querySelectorAll('.card, .admin-stats').forEach(function(card) {
// Show all for admin
if (!isModerator) { card.style.display = ''; return; }
// For moderator: only show Learning Hub CMS cards and editor
var header = card.querySelector('.card-header h3');
var headerText = header ? header.textContent.trim() : '';
var isLearning = headerText.indexOf('Learning Hub') !== -1;
var isEditor = card.id === 'lh-cms-editor';
if (isLearning || isEditor) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
// Hide module-header subtitle for moderators
var moduleHeader = adminTab.querySelector('.module-header');
if (moduleHeader && isModerator) {
moduleHeader.querySelector('h2').innerHTML = '<i class="fas fa-pen-to-square"></i> Content Manager';
moduleHeader.querySelector('p').textContent = 'Create and manage Learning Hub content';
}
}
if (!isModerator) {
loadSettings();
loadUsers();
}
loadSettings();
loadUsers();
}
// ---- SETTINGS + STATS ----

View file

@ -82,21 +82,20 @@ document.addEventListener('DOMContentLoaded', function() {
}
if (userName) userName.textContent = user.name || user.email;
// Show admin tab for admins; show as "Content Manager" for moderators
// Show admin tab for admins only
var adminTabBtn = document.getElementById('admin-tab-btn');
if (adminTabBtn) {
if (user.role === 'admin') {
adminTabBtn.classList.remove('hidden');
adminTabBtn.querySelector('span').textContent = 'Admin';
adminTabBtn.querySelector('i').className = 'fas fa-user-shield';
} else if (user.role === 'moderator') {
adminTabBtn.classList.remove('hidden');
adminTabBtn.querySelector('span').textContent = 'Content Manager';
adminTabBtn.querySelector('i').className = 'fas fa-pen-to-square';
} else {
adminTabBtn.classList.add('hidden');
}
if (user.role === 'admin') adminTabBtn.classList.remove('hidden');
else adminTabBtn.classList.add('hidden');
}
// Show Content Manager tab for admin + moderator
var cmsTabBtn = document.getElementById('cms-tab-btn');
if (cmsTabBtn) {
if (user.role === 'admin' || user.role === 'moderator') cmsTabBtn.classList.remove('hidden');
else cmsTabBtn.classList.add('hidden');
}
// Store role globally for JS modules to check
window._userRole = user.role;

View file

@ -14,11 +14,9 @@
if (e.detail && e.detail.tab === 'learning') {
if (!loaded) { loadFeed(); loadCategories(); loaded = true; }
}
// Load CMS for both admin and moderator when they open admin/content manager tab
if (e.detail && e.detail.tab === 'admin') {
if (!cmsLoaded && (window._userRole === 'admin' || window._userRole === 'moderator')) {
loadCms(); cmsLoaded = true;
}
// Load CMS when Content Manager tab is opened
if (e.detail && e.detail.tab === 'cms') {
if (!cmsLoaded) { loadCms(); cmsLoaded = true; }
}
});
@ -407,8 +405,13 @@
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var el = document.getElementById('lh-cms-stats');
if (el) el.textContent = data.stats.publishedContent + ' published, ' + data.stats.totalContent + ' total, ' + data.stats.totalCategories + ' categories, ' + data.stats.totalAttempts + ' quiz attempts';
var s = data.stats;
var set = function(id, val) { var el = document.getElementById(id); if (el) el.textContent = val; };
set('cms-stat-published', s.publishedContent);
set('cms-stat-drafts', s.totalContent - s.publishedContent);
set('cms-stat-categories', s.totalCategories);
set('cms-stat-quizzes', s.totalQuizzes);
set('cms-stat-attempts', s.totalAttempts);
});
}
@ -421,18 +424,18 @@
if (!container) return;
if (data.categories.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);margin:0;">No categories yet.</p>';
container.innerHTML = '<div style="font-size:12px;color:var(--g400);">No categories yet</div>';
} else {
container.innerHTML = data.categories.map(function(c) {
return '<div style="display:flex;align-items:center;gap:8px;padding:4px 0;">' +
'<span style="font-size:13px;font-weight:500;flex:1;">' + esc(c.name) + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + c.content_count + ' items</span>' +
'<button class="btn-sm btn-ghost lh-cms-del-cat" data-id="' + c.id + '" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-trash"></i></button>' +
return '<div class="cms-cat-item">' +
'<span class="cms-cat-name"><i class="fas fa-folder" style="color:var(--amber);margin-right:6px;font-size:11px;"></i>' + esc(c.name) + '</span>' +
'<span class="cms-cat-count">' + c.content_count + '</span>' +
'<button class="lh-cms-del-cat" data-id="' + c.id + '" style="border:none;background:none;cursor:pointer;color:var(--g400);font-size:11px;padding:2px;" title="Delete"><i class="fas fa-times"></i></button>' +
'</div>';
}).join('');
}
// Update category dropdown in editor
// Update category dropdowns (editor + filter)
var sel = document.getElementById('lh-cms-edit-category');
if (sel) {
sel.innerHTML = '<option value="">Uncategorized</option>';
@ -440,6 +443,13 @@
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
}
var filterSel = document.getElementById('cms-filter-category');
if (filterSel) {
filterSel.innerHTML = '<option value="all">All Categories</option>';
data.categories.forEach(function(c) {
filterSel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
}
});
}
@ -487,28 +497,69 @@
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">No content yet. Click "New Content" to create.</p>';
return;
}
container.innerHTML = data.content.map(function(item) {
var statusBadge = item.published
? '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--green-light,#d1fae5);color:var(--green);">Published</span>'
: '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g500);">Draft</span>';
var qBadge = item.question_count > 0 ? ' <span style="font-size:11px;color:var(--amber);">' + item.question_count + 'Q</span>' : '';
return '<div class="lh-cms-content-item" data-id="' + item.id + '" style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--g100);cursor:pointer;">' +
'<div style="flex:1;min-width:0;">' +
'<div style="font-size:13px;font-weight:500;">' + esc(item.title) + qBadge + '</div>' +
'<div style="font-size:11px;color:var(--g500);">' + (item.category_name || 'Uncategorized') + (item.subject ? ' | ' + esc(item.subject) : '') + '</div>' +
'</div>' +
statusBadge +
'<i class="fas fa-chevron-right" style="color:var(--g300);font-size:12px;"></i>' +
'</div>';
}).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 = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-file-circle-plus" style="font-size:24px;display:block;margin-bottom:8px;"></i>No content yet. Click "New Content" to get started.</div>';
return;
}
container.innerHTML = items.map(function(item) {
var statusBadge = item.published
? '<span class="cms-badge cms-badge-pub">Published</span>'
: '<span class="cms-badge cms-badge-draft">Draft</span>';
var typeLabel = item.content_type === 'quiz' ? 'Quiz' : item.content_type === 'pearl' ? 'Pearl' : 'Article';
var qBadge = item.question_count > 0 ? ' <span style="color:var(--amber);">' + item.question_count + 'Q</span>' : '';
var date = item.updated_at ? new Date(item.updated_at).toLocaleDateString() : '';
return '<div class="cms-table-row lh-cms-content-item" data-id="' + item.id + '">' +
'<span class="cms-col-title">' + esc(item.title) + qBadge + '<br><span class="cms-title-sub">' + esc(item.subject || '') + '</span></span>' +
'<span class="cms-col-cat">' + esc(item.category_name || 'Uncategorized') + '</span>' +
'<span class="cms-col-type">' + typeLabel + '</span>' +
'<span class="cms-col-status">' + statusBadge + '</span>' +
'<span class="cms-col-date">' + date + '</span>' +
'<span class="cms-col-actions"><i class="fas fa-pen" style="color:var(--g400);"></i></span>' +
'</div>';
}).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 = '<i class="fas fa-edit"></i> 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 = '<i class="fas fa-edit"></i> 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() {

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function safeAppUrl() {
return escHtml((process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''));
}
// ============================================================
// EMAIL TEMPLATES — responsive, max-width constrained
// ============================================================
function emailWrapper(body) {
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:'Segoe UI',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:40px 20px;">
<tr><td align="center">
<table width="520" cellpadding="0" cellspacing="0" style="background:white;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<tr><td style="background:linear-gradient(135deg,#2563eb 0%,#7c3aed 100%);padding:28px 36px;">
<p style="margin:0;color:white;font-size:22px;font-weight:700;">🩺 Pediatric AI Scribe</p>
<p style="margin:6px 0 0;color:rgba(255,255,255,0.8);font-size:13px;">AI-Powered Clinical Documentation</p>
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<style>
body,table,td{font-family:'Segoe UI',Roboto,Arial,sans-serif;}
@media only screen and (max-width:600px){
.email-outer{padding:16px 8px !important;}
.email-inner{width:100% !important;min-width:0 !important;}
.email-body{padding:24px 20px !important;}
.email-header{padding:20px 20px !important;}
.email-footer{padding:12px 20px 20px !important;}
.email-btn{padding:12px 24px !important;font-size:14px !important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#f3f4f6;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f3f4f6;">
<tr><td class="email-outer" style="padding:32px 16px;" align="center">
<table role="presentation" class="email-inner" cellpadding="0" cellspacing="0" border="0" style="background:white;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);max-width:520px;width:100%;">
<tr><td class="email-header" style="background:linear-gradient(135deg,#2563eb 0%,#7c3aed 100%);padding:24px 32px;">
<p style="margin:0;color:white;font-size:20px;font-weight:700;">Pediatric AI Scribe</p>
<p style="margin:4px 0 0;color:rgba(255,255,255,0.8);font-size:12px;">AI-Powered Clinical Documentation</p>
</td></tr>
<tr><td style="padding:36px;">${body}</td></tr>
<tr><td style="padding:16px 36px 28px;border-top:1px solid #e5e7eb;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent from Pediatric AI Scribe. If you did not request this, you can safely ignore it no action is needed and your account remains secure.</p>
<tr><td class="email-body" style="padding:32px;">${body}</td></tr>
<tr><td class="email-footer" style="padding:14px 32px 24px;border-top:1px solid #e5e7eb;">
<p style="margin:0;color:#9ca3af;font-size:11px;line-height:1.5;">This email was sent by Pediatric AI Scribe. If you did not request this, you can safely ignore it.</p>
</td></tr>
</table>
</td></tr>
@ -31,14 +55,20 @@ function emailWrapper(body) {
</body></html>`;
}
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 `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px auto;"><tr><td align="center" style="border-radius:8px;background:linear-gradient(135deg,#2563eb,#7c3aed);">
<a class="email-btn" href="${escHtml(href)}" target="_blank" style="display:inline-block;color:white;padding:13px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;font-family:'Segoe UI',Roboto,Arial,sans-serif;">${escHtml(label)}</a>
</td></tr></table>`;
}
function linkFallback(url) {
return `<p style="color:#6b7280;font-size:12px;margin:16px 0 4px;">Button not working? Copy this link into your browser:</p>
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:11px;word-break:break-all;color:#374151;margin:0;max-width:100%;overflow-wrap:break-word;">${escHtml(url)}</p>`;
}
// Email helper — DB settings override env vars
async function getSmtpTransport() {
var nodemailer = require('nodemailer');
// 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(
`<p style="margin:0 0 8px;font-size:22px;">👋 Welcome aboard, ${name}!</p>
<p style="color:#4b5563;margin:12px 0 24px;line-height:1.6;">${verifyBody.replace(/\n/g, '<br>')}</p>
<p style="text-align:center;margin:28px 0;">
<a href="${verifyUrl}" style="${btnStyle()}">Verify My Email</a>
</p>
<p style="color:#6b7280;font-size:13px;margin:20px 0 4px;">Button not working? Copy and paste this link into your browser:</p>
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:12px;word-break:break-all;color:#374151;margin:0;">${verifyUrl}</p>
<p style="color:#9ca3af;font-size:12px;margin:20px 0 0;">This link expires in 24 hours.</p>`
`<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']);
// 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('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#ef4444;">Invalid or Expired Link</h2><p><a href="' + (process.env.APP_URL || '/') + '">Go to app</a></p></body></html>');
return res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#ef4444;">Invalid or Expired Link</h2><p><a href="' + safeAppUrl() + '">Go to app</a></p></body></html>');
}
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
await db.run('INSERT INTO audit_log (user_id, action) VALUES (?, ?)', [user.id, 'email_verified']);
var safeName = user.name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#10b981;">✅ Email Verified!</h2><p>Welcome, ' + safeName + '!</p><p><a href="' + (process.env.APP_URL || '/') + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;">Open App</a></p></body></html>');
} catch (err) { res.status(500).send('Error: ' + err.message); }
res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#10b981;">Email Verified!</h2><p>Welcome, ' + escHtml(user.name) + '!</p><p style="margin-top:20px;"><a href="' + safeAppUrl() + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;">Open App</a></p></body></html>');
} catch (err) { console.error('[Auth] Verify error:', err.message); res.status(500).send('Verification failed. Please try again.'); }
});
// RESEND VERIFICATION
@ -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(
`<p style="margin:0 0 8px;font-size:20px;">📧 Verify your email</p>
<p style="color:#4b5563;margin:12px 0 24px;line-height:1.6;">Here's a fresh verification link for your account. Click below to confirm your email and get started.</p>
<p style="text-align:center;margin:28px 0;">
<a href="${verifyUrl}" style="${btnStyle()}">Verify My Email</a>
</p>
<p style="color:#6b7280;font-size:13px;margin:20px 0 4px;">Button not working? Copy and paste this link into your browser:</p>
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:12px;word-break:break-all;color:#374151;margin:0;">${verifyUrl}</p>
<p style="color:#9ca3af;font-size:12px;margin:20px 0 0;">This link expires in 24 hours.</p>`
`<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) { 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(
`<p style="margin:0 0 8px;font-size:20px;">🔐 Password reset request</p>
<p style="color:#4b5563;margin:12px 0 24px;line-height:1.6;">${resetBody.replace(/\n/g, '<br>')}</p>
<p style="text-align:center;margin:28px 0;">
<a href="${resetUrl}" style="${btnStyle()}">Reset My Password</a>
</p>
<p style="color:#6b7280;font-size:13px;margin:20px 0 4px;">Button not working? Copy and paste this link into your browser:</p>
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:12px;word-break:break-all;color:#374151;margin:0;">${resetUrl}</p>`
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Password reset request</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(resetBody).replace(/\n/g, '<br>')}</p>
${btnHtml(resetUrl, 'Reset My Password')}
${linkFallback(resetUrl)}`
));
res.json({ success: true, message: 'If account exists, reset email sent' });
} catch (err) { 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;