diff --git a/public/components/cms.html b/public/components/cms.html index 70bd50e..5766fc8 100644 --- a/public/components/cms.html +++ b/public/components/cms.html @@ -86,13 +86,13 @@ - +
@@ -179,11 +179,8 @@ diff --git a/public/index.html b/public/index.html index 430fa82..c862eaa 100644 --- a/public/index.html +++ b/public/index.html @@ -21,7 +21,8 @@ - + + diff --git a/public/js/app.js b/public/js/app.js index 9a49c47..7743e15 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -6,7 +6,6 @@ (function() { function sendError(data) { try { - var token = localStorage.getItem('ped_scribe_token') || ''; navigator.sendBeacon('/api/logs/client-error', new Blob( [JSON.stringify(data)], { type: 'application/json' } )); @@ -448,7 +447,7 @@ function transcribeAudio(blob) { formData.append('audio', blob, 'audio.webm'); return fetch('/api/transcribe', { method: 'POST', - headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') }, + credentials: 'same-origin', body: formData }).then(function(r) { return r.json(); }); } diff --git a/public/js/auth.js b/public/js/auth.js index fee9823..686992b 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -24,21 +24,17 @@ document.addEventListener('DOMContentLoaded', function() { // Make sure forms are visible/hidden correctly on load showLoginForm(); - // Check for saved session - var savedToken = localStorage.getItem(TOKEN_KEY); - if (savedToken) { - // Hide auth screen immediately to prevent flash while verifying token - authScreen.classList.add('hidden'); - fetch('/api/auth/me', { - headers: { 'Authorization': 'Bearer ' + savedToken } - }) + // Check for saved session via httpOnly cookie (cookie sent automatically) + // Optimistically hide auth screen — shows briefly if no session exists + authScreen.classList.add('hidden'); + fetch('/api/auth/me', { credentials: 'same-origin' }) .then(function(r) { if (r.ok) return r.json(); - throw new Error('expired'); + throw new Error('not authenticated'); }) .then(function(data) { if (data && data.user) { - enterApp(data.user, savedToken); + enterApp(data.user); } else { authScreen.classList.remove('hidden'); clearSession(); @@ -48,7 +44,6 @@ document.addEventListener('DOMContentLoaded', function() { authScreen.classList.remove('hidden'); clearSession(); }); - } // ---- HELPER FUNCTIONS ---- @@ -70,9 +65,8 @@ document.addEventListener('DOMContentLoaded', function() { if (forgotForm) forgotForm.style.display = 'block'; } - function enterApp(user, token) { - window.AUTH_TOKEN = token; - localStorage.setItem(TOKEN_KEY, token); + function enterApp(user) { + // Token is now in httpOnly cookie — do NOT store in localStorage/window localStorage.setItem(USER_KEY, JSON.stringify(user)); if (authScreen) authScreen.style.display = 'none'; @@ -133,17 +127,16 @@ document.addEventListener('DOMContentLoaded', function() { } function clearSession() { - localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); window.AUTH_TOKEN = null; + // Tell server to clear the httpOnly cookie (fire-and-forget) + fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }).catch(function() {}); } - // Global auth header helper + // Auth headers — cookie is sent automatically for same-origin requests + // No Authorization header needed; cookie handles authentication window.getAuthHeaders = function() { - return { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem(TOKEN_KEY) || '') - }; + return { 'Content-Type': 'application/json' }; }; // ---- FORM TOGGLE LINKS ---- @@ -286,8 +279,8 @@ document.addEventListener('DOMContentLoaded', function() { return; } - if (data.success && data.token && data.user) { - enterApp(data.user, data.token); + if (data.success && data.user) { + enterApp(data.user); showToast('Welcome, ' + data.user.name + '!', 'success'); } else { showToast(data.error || 'Login failed', 'error'); @@ -335,8 +328,8 @@ document.addEventListener('DOMContentLoaded', function() { hideLoading(); console.log('[Auth] Register response:', JSON.stringify(data).substring(0, 200)); - if (data.success && data.token && data.user) { - enterApp(data.user, data.token); + if (data.success && data.user) { + enterApp(data.user); showToast(data.message || 'Account created!', 'success'); } else if (data.success && data.needsVerification) { showToast(data.message || 'Check email to verify', 'success'); diff --git a/public/js/learningHub.js b/public/js/learningHub.js index fc30ec7..a333c47 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -512,21 +512,70 @@ var panel = document.getElementById('lh-ai-panel'); if (!panel) return; panel.classList.remove('hidden'); - // Populate model selector populateAiModelSelect(); + + // Sync content type selector with the editor's current type + var editorType = (document.getElementById('lh-cms-edit-type') || {}).value || 'article'; + var ctype = document.getElementById('lh-ai-ctype'); + if (ctype) ctype.value = editorType; + updateAiOptions(editorType); + // Hide WebDAV tab if Nextcloud not configured fetch('/api/auth/me', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(d) { var tab = document.getElementById('lh-ai-tab-webdav'); if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none'; - if (d.user && d.user.webdav_learning_path) { - _aiWebdavCurrentPath = d.user.webdav_learning_path || '/'; - } + if (d.user && d.user.webdav_learning_path) _aiWebdavCurrentPath = d.user.webdav_learning_path || '/'; }); + + // Wire ctype change once (use a flag so it doesn't stack on re-opens) + var ctypeEl = document.getElementById('lh-ai-ctype'); + var toggleEl = document.getElementById('lh-ai-q-toggle'); + if (ctypeEl && !ctypeEl._wired) { + ctypeEl.addEventListener('change', function() { updateAiOptions(ctypeEl.value); }); + ctypeEl._wired = true; + } + if (toggleEl && !toggleEl._wired) { + toggleEl.addEventListener('change', function() { updateAiOptions((document.getElementById('lh-ai-ctype') || {}).value); }); + toggleEl._wired = true; + } + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } + function updateAiOptions(type) { + var wordsField = document.getElementById('lh-ai-words-field'); + var slidesField = document.getElementById('lh-ai-slides-field'); + var toggleLabel = document.getElementById('lh-ai-q-toggle-label'); + var toggleCb = document.getElementById('lh-ai-q-toggle'); + var countWrap = document.getElementById('lh-ai-q-count-wrap'); + + // Word count: only for article and pearl + if (wordsField) wordsField.classList.toggle('hidden', type === 'quiz' || type === 'presentation'); + // Slide count: only for presentation + if (slidesField) slidesField.classList.toggle('hidden', type !== 'presentation'); + + // Questions section + if (type === 'quiz') { + // Quiz: always generate questions, no toggle, show count directly + if (toggleLabel) toggleLabel.style.display = 'none'; + if (toggleCb) { toggleCb.checked = true; toggleCb.disabled = true; } + if (countWrap) countWrap.style.display = 'flex'; + } else if (type === 'presentation') { + // Presentation: no questions at all + if (toggleLabel) toggleLabel.style.display = 'none'; + if (toggleCb) { toggleCb.checked = false; toggleCb.disabled = true; } + if (countWrap) countWrap.style.display = 'none'; + } else { + // Article / pearl: optional toggle + if (toggleLabel) toggleLabel.style.display = 'flex'; + if (toggleCb) { toggleCb.disabled = false; } + var checked = toggleCb ? toggleCb.checked : false; + if (countWrap) countWrap.style.display = checked ? 'flex' : 'none'; + } + } + function closeAiPanel() { var panel = document.getElementById('lh-ai-panel'); if (panel) panel.classList.add('hidden'); @@ -635,16 +684,22 @@ function runAiGenerate() { var activeTab = document.querySelector('.lh-ai-tab.active'); var tabName = activeTab ? activeTab.dataset.aitab : 'topic'; - var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel(); - var questionCount = document.getElementById('lh-ai-q-count') ? document.getElementById('lh-ai-q-count').value : 5; - var contentType = document.getElementById('lh-ai-ctype') ? document.getElementById('lh-ai-ctype').value : 'article'; - var refinement = document.getElementById('lh-ai-refinement') ? document.getElementById('lh-ai-refinement').value.trim() : ''; + var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel(); + var contentType = document.getElementById('lh-ai-ctype') ? document.getElementById('lh-ai-ctype').value : 'article'; + var refinement = document.getElementById('lh-ai-refinement') ? document.getElementById('lh-ai-refinement').value.trim() : ''; + var toggleCb = document.getElementById('lh-ai-q-toggle'); + var generateQs = contentType === 'quiz' || (toggleCb && toggleCb.checked); + var questionCount = generateQs ? (document.getElementById('lh-ai-q-count') ? document.getElementById('lh-ai-q-count').value : 5) : 0; + var wordCount = document.getElementById('lh-ai-word-count') ? document.getElementById('lh-ai-word-count').value : ''; + var slideCount = document.getElementById('lh-ai-slide-count') ? document.getElementById('lh-ai-slide-count').value : ''; var formData = new FormData(); formData.append('model', model); formData.append('questionCount', questionCount); formData.append('contentType', contentType); if (refinement) formData.append('refinement', refinement); + if (wordCount) formData.append('wordCount', wordCount); + if (slideCount) formData.append('slideCount', slideCount); if (tabName === 'topic') { var topic = document.getElementById('lh-ai-topic') ? document.getElementById('lh-ai-topic').value.trim() : ''; @@ -663,11 +718,10 @@ if (btn) { btn.disabled = true; btn.innerHTML = ' Generating...'; } showLoading('AI is generating content…'); - // For FormData, do NOT set Content-Type (browser sets it with boundary) - var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; + // FormData: no Content-Type header (browser sets it with boundary); cookie sent automatically fetch('/api/admin/learning/ai-generate', { method: 'POST', - headers: { 'Authorization': 'Bearer ' + token }, + credentials: 'same-origin', body: formData }) .then(function(r) { return r.json(); }) @@ -1330,11 +1384,20 @@ function showDeleteConfirm() { var id = document.getElementById('lh-cms-edit-id').value; if (!id) return; - var title = (document.getElementById('lh-cms-edit-title').value || 'this content').substring(0, 50); - var bar = document.getElementById('lh-delete-confirm-bar'); - var titleEl = document.getElementById('lh-delete-confirm-title'); + var title = (document.getElementById('lh-cms-edit-title').value || 'this content').substring(0, 60); + var ctype = (document.getElementById('lh-cms-edit-type') || {}).value || 'article'; + var bar = document.getElementById('lh-delete-confirm-bar'); + var msgEl = document.getElementById('lh-delete-confirm-msg'); if (!bar) return; - if (titleEl) titleEl.textContent = '"' + title + '"'; + + // Build wording based on content type + var typeLabel = { article: 'article', quiz: 'quiz', pearl: 'clinical pearl', presentation: 'presentation' }[ctype] || 'content'; + var consequence = ''; + if (ctype === 'quiz') consequence = ' All quiz questions will be permanently removed.'; + else if (ctype === 'article' || ctype === 'pearl') consequence = ' Any quiz questions attached to it will also be removed.'; + // Presentation has no questions → no extra wording + + if (msgEl) msgEl.innerHTML = 'Delete the ' + typeLabel + ' "' + esc(title) + '"?' + consequence + ' This cannot be undone.'; bar.classList.remove('hidden'); bar.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 45645cc..9c66a49 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -6,11 +6,13 @@ const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'; async function authMiddleware(req, res, next) { var token = null; - var authHeader = req.headers.authorization; - if (authHeader && authHeader.startsWith('Bearer ')) { - token = authHeader.substring(7); - } else if (req.cookies && req.cookies.token) { - token = req.cookies.token; + // 1. httpOnly cookie (primary — JS cannot read, immune to XSS token theft) + if (req.cookies && req.cookies.ped_auth) { + token = req.cookies.ped_auth; + } + // 2. Authorization header (backward compat for API clients) + else if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { + token = req.headers.authorization.substring(7); } if (!token) { diff --git a/src/routes/auth.js b/src/routes/auth.js index 8e27639..474185f 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -8,6 +8,23 @@ const crypto = require('crypto'); const db = require('../db/database'); const { JWT_SECRET, authMiddleware } = require('../middleware/auth'); +// ── Cookie helper ───────────────────────────────────────────── +// Sets the JWT as a secure, httpOnly cookie so JS cannot read it +function setAuthCookie(res, token) { + var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL; + res.cookie('ped_auth', token, { + httpOnly: true, + secure: !!isProduction, // HTTPS only in production + sameSite: 'lax', // Allows normal navigation, blocks CSRF from other origins + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms + path: '/' + }); +} + +function clearAuthCookie(res) { + res.clearCookie('ped_auth', { path: '/' }); +} + // ============================================================ // HTML HELPERS // ============================================================ @@ -143,8 +160,9 @@ router.post('/register', async (req, res) => { if (!smtpHost) { await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]); var token = jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: '7d' }); + setAuthCookie(res, token); return res.json({ - success: true, token: token, + success: true, user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true }, message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).' }); @@ -229,9 +247,10 @@ router.post('/login', async (req, res) => { var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]); + setAuthCookie(res, token); res.json({ - success: true, token: token, + success: true, user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified } }); } catch (err) { console.error('[Auth] Login error:', err.message); res.status(500).json({ error: 'Login failed' }); } @@ -299,6 +318,12 @@ router.post('/reset-password', async (req, res) => { } catch (err) { console.error('[Auth] Reset password error:', err.message); res.status(500).json({ error: 'Password reset failed' }); } }); +// Logout — clears the auth cookie server-side +router.post('/logout', function(req, res) { + clearAuthCookie(res); + res.json({ success: true }); +}); + // Get current user router.get('/me', authMiddleware, async (req, res) => { try { diff --git a/src/routes/learningAI.js b/src/routes/learningAI.js index c5b5f7c..573da1b 100644 --- a/src/routes/learningAI.js +++ b/src/routes/learningAI.js @@ -52,7 +52,7 @@ async function extractText(buffer, mimetype, filename) { // ── Build AI prompt ────────────────────────────────────────── function buildGeneratePrompt(opts) { - var { topic, docText, contentType, questionCount, refinement } = opts; + var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount } = opts; var source = docText ? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 12000) + '\n"""\n' @@ -62,8 +62,9 @@ function buildGeneratePrompt(opts) { // ── Presentation: return Marp markdown, not JSON ── if (contentType === 'presentation') { + var slideHint = slideCount ? slideCount + ' slides' : '8-12 slides'; return source + '\n' + - 'Create a professional Marp presentation (8-12 slides) suitable for medical education. ' + + 'Create a professional Marp presentation (' + slideHint + ') suitable for medical education. ' + 'Each slide should be focused and readable.' + refineInstr + ` Return ONLY valid Marp markdown (no JSON, no code fences). Start with frontmatter: @@ -80,13 +81,18 @@ Then each slide separated by ---. Guidelines: - Do NOT include HTML tags or inline styles`; } + var wordHint = wordCount ? ' Target approximately ' + wordCount + ' words for the body.' : ''; + var qInstr = parseInt(questionCount) > 0 + ? 'then generate ' + questionCount + ' quiz questions.' + : 'Do NOT include quiz questions (questions array should be empty []).'; + var typeInstr = ''; if (contentType === 'quiz') { - typeInstr = 'This is a quiz-only resource. Write a brief introductory body (1-2 paragraphs), then generate ' + questionCount + ' quiz questions.'; + typeInstr = 'This is a quiz-only resource. Write a brief introductory body (1-2 paragraphs),' + wordHint + ' ' + qInstr; } else if (contentType === 'pearl') { - typeInstr = 'This is a clinical pearl. Write a concise, high-impact body (2-4 paragraphs focusing on key takeaways), then generate ' + questionCount + ' quiz question(s).'; + typeInstr = 'This is a clinical pearl. Write a concise, high-impact body (2-4 paragraphs focusing on key takeaways).' + wordHint + ' ' + qInstr; } else { - typeInstr = 'This is an article. Write a comprehensive body (4-8 paragraphs), then generate ' + questionCount + ' quiz questions.'; + typeInstr = 'This is an article. Write a comprehensive, well-structured body.' + wordHint + ' ' + qInstr; } return source + '\n' + typeInstr + refineInstr + ` @@ -122,10 +128,12 @@ router.post('/ai-generate', upload.single('file'), async function(req, res) { try { var topic = req.body.topic || ''; var contentType = req.body.contentType || 'article'; - var questionCount = Math.min(parseInt(req.body.questionCount) || 5, 20); + var questionCount = Math.min(parseInt(req.body.questionCount) || 0, 20); var model = req.body.model || null; var refinement = req.body.refinement || ''; var webdavPath = req.body.webdavPath || ''; + var wordCount = parseInt(req.body.wordCount) || 0; + var slideCount = parseInt(req.body.slideCount) || 0; var docText = ''; @@ -159,7 +167,7 @@ router.post('/ai-generate', upload.single('file'), async function(req, res) { return res.status(400).json({ error: 'Provide a topic or upload a file.' }); } - var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement }); + var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount }); var result = await callAI( [{ role: 'user', content: prompt }],