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:
parent
0327afe94c
commit
82a0911d24
8 changed files with 388 additions and 227 deletions
|
|
@ -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
128
public/components/cms.html
Normal 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... 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>
|
||||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue