AI panel context-aware options, delete wording, httpOnly cookie auth

AI Generate panel:
- Content type synced from editor on open
- Article/Pearl: optional word count field
- Presentation: optional slide count field
- Questions row: checkbox toggle for article/pearl (optional), always on for quiz,
  hidden for presentation; count input shown only when enabled
- Backend: passes wordCount/slideCount to prompt; questionCount=0 skips questions

Delete confirmation:
- Wording built dynamically from content type
- Presentation: no mention of questions
- Quiz: "All quiz questions will be permanently removed"
- Article/pearl: "Any quiz questions attached will also be removed"

Auth refactor — JWT → httpOnly cookie:
- Backend: setAuthCookie() sets ped_auth as httpOnly, Secure, SameSite=Lax, 7d
- Backend: clearAuthCookie() on POST /api/auth/logout (new endpoint)
- Backend: auth middleware reads ped_auth cookie first, falls back to Bearer header
- Login/register: no longer return token in JSON body; cookie is the session
- Frontend: getAuthHeaders() returns only Content-Type (no Authorization header)
- Frontend: session check uses /api/auth/me via cookie (no localStorage token)
- Frontend: clearSession() calls /api/auth/logout to clear cookie server-side
- Frontend: no token in localStorage; window.AUTH_TOKEN removed
- All FormData fetches use credentials:'same-origin' (cookie sent automatically)
- index.html: optimistic auth-screen hide (removed localStorage token check)

Tested: login, logout, /api/auth/me, admin routes, FormData uploads, cookie httpOnly
flag, 401 without cookie, cookie cleared on logout.
This commit is contained in:
Daniel Onyejesi 2026-03-24 01:46:07 -04:00
parent 6471aced55
commit ad40678b6e
8 changed files with 182 additions and 65 deletions

View file

@ -86,13 +86,13 @@
<button id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save</button>
</div>
</div>
<!-- Inline delete confirmation bar (replaces the browser confirm popup) -->
<!-- Inline delete confirmation bar — message built dynamically in JS -->
<div id="lh-delete-confirm-bar" class="lh-delete-confirm-bar hidden">
<i class="fas fa-triangle-exclamation"></i>
<span>Delete <strong id="lh-delete-confirm-title"></strong>? All questions will be removed. This cannot be undone.</span>
<span id="lh-delete-confirm-msg"></span>
<div style="display:flex;gap:8px;margin-left:auto;flex-shrink:0;">
<button id="btn-lh-delete-cancel" class="btn-sm btn-ghost">Cancel</button>
<button id="btn-lh-delete-yes" class="btn-sm" style="background:var(--red);color:white;">Delete</button>
<button id="btn-lh-delete-yes" class="btn-sm" style="background:var(--red);color:white;">Yes, Delete</button>
</div>
</div>
@ -179,11 +179,8 @@
<!-- Generation options -->
<div class="lh-ai-options">
<!-- Row 1: type + model -->
<div class="lh-ai-opt-row">
<div class="lh-ai-opt-field">
<label>Questions to generate</label>
<input type="number" id="lh-ai-q-count" value="5" min="0" max="20" class="cms-input-sm" style="width:80px;">
</div>
<div class="lh-ai-opt-field">
<label>Content type</label>
<select id="lh-ai-ctype" class="cms-input-sm">
@ -198,9 +195,38 @@
<select id="lh-ai-model" class="cms-input-sm tab-model-select" style="width:100%;"></select>
</div>
</div>
<!-- Row 2: context-aware size options -->
<div class="lh-ai-opt-row" id="lh-ai-size-row">
<!-- Article / Pearl: word count hint -->
<div class="lh-ai-opt-field" id="lh-ai-words-field">
<label>Approx. word count <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<input type="number" id="lh-ai-word-count" value="" min="100" max="3000" step="50" class="cms-input-sm" style="width:100px;" placeholder="e.g. 600">
</div>
<!-- Presentation: slide count -->
<div class="lh-ai-opt-field hidden" id="lh-ai-slides-field">
<label>Number of slides <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<input type="number" id="lh-ai-slide-count" value="" min="3" max="30" class="cms-input-sm" style="width:80px;" placeholder="e.g. 10">
</div>
</div>
<!-- Row 3: quiz questions — always visible for quiz, optional toggle for others -->
<div id="lh-ai-quiz-row" class="lh-ai-opt-row" style="align-items:center;padding:10px 12px;border:1.5px solid var(--g200);border-radius:8px;background:white;">
<!-- Toggle (hidden for quiz type) -->
<label id="lh-ai-q-toggle-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;font-weight:500;color:var(--g700);margin:0;">
<input type="checkbox" id="lh-ai-q-toggle" style="accent-color:var(--blue);width:15px;height:15px;">
Also generate quiz questions
</label>
<div id="lh-ai-q-count-wrap" style="display:flex;align-items:center;gap:8px;margin-left:auto;">
<label style="font-size:12px;color:var(--g500);margin:0;">How many</label>
<input type="number" id="lh-ai-q-count" value="5" min="1" max="20" class="cms-input-sm" style="width:70px;">
</div>
</div>
<!-- Row 4: instructions -->
<div class="lh-ai-opt-field" style="width:100%;">
<label>Special instructions (optional)</label>
<input type="text" id="lh-ai-refinement" class="cms-input-sm" style="width:100%;" placeholder="e.g., Focus on ER management, make it suitable for residents, use case-based format">
<label>Special instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<input type="text" id="lh-ai-refinement" class="cms-input-sm" style="width:100%;" placeholder="e.g., Focus on ER management, suitable for residents, case-based format">
</div>
</div>

View file

@ -21,7 +21,8 @@
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192.png">
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512.png">
<script>if (localStorage.getItem('ped_scribe_token')) { document.documentElement.classList.add('has-session'); }</script>
<!-- Auth screen hidden optimistically on load; auth.js verifies via /api/auth/me cookie check -->
<script>document.documentElement.classList.add('has-session');</script>
<style>html.has-session #auth-screen { display: none !important; }</style>
</head>
<body>

View file

@ -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(); });
}

View file

@ -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');

View file

@ -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 = '<i class="fas fa-spinner fa-spin"></i> 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 + ' <strong>"' + esc(title) + '"</strong>?' + consequence + ' This cannot be undone.';
bar.classList.remove('hidden');
bar.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}

View file

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

View file

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

View file

@ -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 }],