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:
parent
6471aced55
commit
ad40678b6e
8 changed files with 182 additions and 65 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
Loading…
Reference in a new issue