diff --git a/assets/js/base.js b/assets/js/base.js index 91a8b00..5a4ce3b 100644 --- a/assets/js/base.js +++ b/assets/js/base.js @@ -468,7 +468,15 @@ try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); } let data; try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } - if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); + // Resync CSRF token from any response body that carries a fresh one + // (bootstrap rotates on success and returns the current token on rejection). + if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token; + if (!resp.ok) { + const err = new Error(data.error || data.message || 'HTTP ' + resp.status); + err.data = data; + err.status = resp.status; + throw err; + } return data; } @@ -2004,6 +2012,7 @@ let _focusedIdx = -1; let _items = []; let _debTimer = null; + let _searchSeq = 0; function _render(items, query) { _items = items.slice(0, maxResults); @@ -2028,16 +2037,21 @@ } async function _search(query) { + // Sequence guard: only the latest query is allowed to render, so a slow + // earlier async source() cannot overwrite a newer query's results. + const seq = ++_searchSeq; dropdown.innerHTML = '
Searching…
'; dropdown.classList.add('is-open'); inputEl.setAttribute('aria-busy', 'true'); try { const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase())); + if (seq !== _searchSeq) return; _render(results, query); } catch(e) { + if (seq !== _searchSeq) return; dropdown.innerHTML = '
Error loading results
'; } finally { - inputEl.setAttribute('aria-busy', 'false'); + if (seq === _searchSeq) inputEl.setAttribute('aria-busy', 'false'); } } @@ -2704,7 +2718,15 @@ } let data; try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } - if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); + // Resync CSRF token from any response body that carries a fresh one + // (bootstrap rotates on success and returns the current token on rejection). + if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token; + if (!resp.ok) { + const err = new Error(data.error || data.message || 'HTTP ' + resp.status); + err.data = data; + err.status = resp.status; + throw err; + } return data; } api.get = url => _apiFetchAuth('GET', url); @@ -2713,6 +2735,79 @@ api.patch = (u, b) => _apiFetchAuth('PATCH', u, b); api.delete = (u, b) => _apiFetchAuth('DELETE', u, b); + /* ================================================================ + TICKET STATUS CHANGE (comment-aware) + lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise + Posts /api/update_ticket.php. If the server rejects with + requires_comment, opens a comment modal, persists the comment via + /api/add_comment.php, then retries the update once WITH the comment. + Rejects with err.cancelled === true if the user cancels the modal. + ================================================================ */ + function _statusCommentModal(newStatus) { + return new Promise(resolve => { + const modalId = 'ltStatusCommentModal' + Date.now(); + const safeStatus = escHtml(newStatus); + document.body.insertAdjacentHTML('beforeend', + ''); + const modalEl = document.getElementById(modalId); + openModal(modalId); + let done = false; + const finish = (value) => { + if (done) return; + done = true; + closeModal(modalId); + setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300); + resolve(value); + }; + modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null)); + document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null)); + document.getElementById(modalId + '_confirm').addEventListener('click', () => { + const ta = document.getElementById(modalId + '_comment'); + const comment = ta ? ta.value.trim() : ''; + if (!comment) { if (ta) ta.focus(); toast.warning('Please enter a reason for this status change.'); return; } + finish(comment); + }); + setTimeout(() => { const ta = document.getElementById(modalId + '_comment'); if (ta) ta.focus(); }, 100); + }); + } + + const ticketStatus = { + submit(ticketId, newStatus, opts) { + opts = opts || {}; + const id = String(ticketId); + const payload = { ticket_id: id, status: newStatus }; + if (opts.comment) payload.comment = opts.comment; + return api.post('/api/update_ticket.php', payload).catch(err => { + if (!(err && err.data && err.data.requires_comment)) throw err; + return _statusCommentModal(newStatus).then(comment => { + if (!comment) { + const cancelErr = new Error('Status change cancelled'); + cancelErr.cancelled = true; + throw cancelErr; + } + // Persist the comment, then retry the status change with it included. + return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment }) + .then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment })); + }); + }); + }, + }; + /* ================================================================ MODULE 54 — MARKDOWN RENDERER lt.markdown.render(mdString) → HTML string (sanitized) @@ -2722,9 +2817,9 @@ ================================================================ */ const markdown = { render(md) { - // Delegate to window.marked if available - if (global.marked) return global.marked.parse(md); - if (global.markdownit) return global.markdownit().render(md); + // Always use the built-in XSS-safe micro-renderer. Do NOT delegate to + // window.marked / window.markdownit: their raw HTML output is not sanitized + // here, so delegating would enable stored XSS if such a lib were ever loaded. // Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr let html = escHtml(md) // Fenced code blocks @@ -2943,6 +3038,7 @@ lightbox, auth, markdown, + ticketStatus, pagination, sidebarSubmenus: { init: initSidebarSubmenus }, }; diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 03628a2..526ef54 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -1000,18 +1000,20 @@ function performQuickStatusChange(ticketId) { if (!quickStatusEl) return; const newStatus = quickStatusEl.value; - lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) + // Close this modal first so the comment modal (if requires_comment) stacks cleanly. + closeQuickStatusModal(); + + lt.ticketStatus.submit(ticketId, newStatus) .then(data => { - closeQuickStatusModal(); - if (data.success) { + if (data && data.success) { lt.toast.success(`Status updated to ${newStatus}`, 3000); showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000); } else { - lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000); + lt.toast.error('Error: ' + ((data && data.error) || 'Unknown error'), 4000); } }) .catch(error => { - closeQuickStatusModal(); + if (error && error.cancelled) return; lt.toast.error('Error updating status', 4000); }); } @@ -1168,8 +1170,9 @@ function populateKanbanCards() { card.dataset.ticketId = ticketId; card.dataset.status = status; card.addEventListener('click', (e) => { - // Don't navigate if drag just ended (drag adds/removes is-dragging briefly) - if (card.dataset.dragged) { delete card.dataset.dragged; return; } + // Don't navigate if a drag just ended. The flag is cleared on a timer + // (see handleKanbanSort), so a genuine later click is not swallowed. + if (card.dataset.dragged) return; window.location.href = '/ticket/' + encodeURIComponent(ticketId); }); card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); }; @@ -1214,6 +1217,9 @@ function populateKanbanCards() { movedCard.dataset.status = newStatus; movedCard.dataset.dragged = '1'; + // Clear the drag flag shortly after the drop so it suppresses only the + // synthetic click fired on drop, not the user's next genuine click. + setTimeout(function () { delete movedCard.dataset.dragged; }, 400); // Optimistically update column counts const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`); @@ -1230,8 +1236,9 @@ function populateKanbanCards() { if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')'; }; - // POST status update via the shared wrapper (adds CSRF + JSON, throws on non-2xx) - lt.api.post('/api/update_ticket.php', { ticket_id: String(ticketId), status: newStatus }) + // Submit via the shared comment-aware helper. Dropping to Closed (or + // reopening) prompts for a required comment and retries; cancel reverts. + lt.ticketStatus.submit(String(ticketId), newStatus) .then(function (data) { if (data && data.success) { lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500); @@ -1241,8 +1248,8 @@ function populateKanbanCards() { revert(); } }) - .catch(function () { - lt.toast.error('Status update failed — reverting'); + .catch(function (error) { + if (!(error && error.cancelled)) lt.toast.error('Status update failed — reverting'); revert(); }); } diff --git a/assets/js/keyboard-shortcuts.js b/assets/js/keyboard-shortcuts.js index d09cea6..f7da6bf 100644 --- a/assets/js/keyboard-shortcuts.js +++ b/assets/js/keyboard-shortcuts.js @@ -6,11 +6,27 @@ // Track currently selected row for J/K navigation let currentSelectedRowIndex = -1; +let lastNavRowCount = -1; + +// Only navigate real, visible rows — skip skeleton placeholders and rows hidden +// by filters/column toggles (offsetParent is null when display:none). +function getNavigableRows() { + return Array.from(document.querySelectorAll('tbody tr')).filter(function(row) { + return !row.classList.contains('lt-skeleton-row') && row.offsetParent !== null; + }); +} function navigateTableRow(direction) { - const rows = document.querySelectorAll('tbody tr'); + const rows = getNavigableRows(); if (rows.length === 0) return; + // Reset the index when the row set changes (e.g. filter/reload) so navigation + // never lands on a stale/hidden index. + if (rows.length !== lastNavRowCount) { + currentSelectedRowIndex = -1; + lastNavRowCount = rows.length; + } + rows.forEach(row => row.classList.remove('keyboard-selected')); if (direction === 'next') { @@ -47,10 +63,8 @@ document.addEventListener('DOMContentLoaded', function() { } }); - // ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer - lt.keys.on('?', function() { - if (window.lt) lt.modal.open('lt-keys-help'); - }); + // Note: the '?' help shortcut is registered by lt.keys.initDefaults(); do not + // re-bind it here or the help modal opens twice. // J: Next row lt.keys.on('j', () => navigateTableRow('next')); diff --git a/assets/js/markdown.js b/assets/js/markdown.js index 77c83f7..27bc63a 100644 --- a/assets/js/markdown.js +++ b/assets/js/markdown.js @@ -41,9 +41,6 @@ function parseMarkdown(markdown) { .replace(/"/g, '"') .replace(/'/g, '''); - // Ticket references (#123456789) - convert to clickable links - html = html.replace(/#(\d{9})\b/g, '#$1'); - // Code blocks (```code```) - preserve content and don't process further const codeBlocks = []; html = html.replace(/```([\s\S]*?)```/g, function(match, code) { @@ -58,6 +55,11 @@ function parseMarkdown(markdown) { return '%%INLINECODE' + (inlineCodes.length - 1) + '%%'; }); + // Ticket references (#123456789) - convert to clickable links. + // Runs AFTER code extraction so a literal #123456789 inside inline/fenced code + // (now replaced by a placeholder) is not turned into a link. + html = html.replace(/#(\d{9})\b/g, '#$1'); + // Tables (must be processed before other block elements) html = parseMarkdownTables(html); @@ -287,25 +289,33 @@ function buildTable(rows) { if (rows.length === 0) return ''; let html = ''; + let inThead = false; + let inTbody = false; - rows.forEach((row, index) => { + rows.forEach((row) => { const cells = row.content.split('|').filter(cell => cell.trim() !== ''); - const tag = row.type === 'header' ? 'th' : 'td'; - const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : ''); + const isHeader = row.type === 'header'; + const tag = isHeader ? 'th' : 'td'; - if (wrapper === 'thead') html += ''; - if (wrapper === 'tbody') html += ''; + if (isHeader && !inThead) { html += ''; inThead = true; } + if (!isHeader && !inTbody) { + if (inThead) { html += ''; inThead = false; } + html += ''; + inTbody = true; + } html += ''; cells.forEach(cell => { html += `<${tag}>${cell.trim()}`; }); html += ''; - - if (row.type === 'header') html += ''; }); - html += '
'; + // Close whichever section is still open so tags are balanced for header-only, + // body-only, and header+body tables alike. + if (inThead) html += ''; + if (inTbody) html += ''; + html += ''; return html; } diff --git a/assets/js/ticket.js b/assets/js/ticket.js index 747ac1a..09155a0 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -291,14 +291,8 @@ function addComment() { // For markdown, use parseMarkdown (sanitizes HTML) displayText = parseMarkdown(commentText); } else { - // For non-markdown, convert line breaks to
and escape HTML - displayText = commentText - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/\n/g, '
'); + // For non-markdown, escape HTML then convert line breaks to
+ displayText = lt.escHtml(commentText).replace(/\n/g, '
'); } // Add new comment to the list @@ -538,11 +532,12 @@ function updateTicketStatus() { return; } cleanup(true); - // Post comment first, then change status + // Post comment first (persists it), then change status with the same + // comment included so the server's requires_comment check passes. const ticketId = getTicketIdFromUrl(); lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment }) - .then(() => performStatusChange(statusSelect, selectedOption, newStatus)) - .catch(() => performStatusChange(statusSelect, selectedOption, newStatus)); + .then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment)) + .catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment)); }); // Focus textarea on open setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100); @@ -552,8 +547,11 @@ function updateTicketStatus() { performStatusChange(statusSelect, selectedOption, newStatus); } -// Extract status change logic into reusable function -function performStatusChange(statusSelect, selectedOption, newStatus) { +// Extract status change logic into reusable function. +// `comment` (optional) is included in the update_ticket payload so requires_comment +// transitions pass server validation. lt.ticketStatus.submit handles the +// comment-aware retry if a comment is required but was not pre-collected. +function performStatusChange(statusSelect, selectedOption, newStatus, comment) { const ticketId = getTicketIdFromUrl(); if (!ticketId) { @@ -561,10 +559,10 @@ function performStatusChange(statusSelect, selectedOption, newStatus) { return; } - // Update status via API - lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) + // Update status via the shared comment-aware helper + lt.ticketStatus.submit(ticketId, newStatus, { comment: comment }) .then(data => { - if (data.success) { + if (data && data.success) { // Update the dropdown to show new status as current (preserve TDS v1.2 classes) const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-'); statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass; @@ -582,12 +580,14 @@ function performStatusChange(statusSelect, selectedOption, newStatus) { window.location.reload(); }, 500); } else { - lt.toast.error('Error updating status: ' + (data.error || 'Unknown error')); + lt.toast.error('Error updating status: ' + ((data && data.error) || 'Unknown error')); // Reset to current status statusSelect.selectedIndex = 0; } }) .catch(error => { + // User cancelled the required-comment modal — silently revert the dropdown + if (error && error.cancelled) { statusSelect.selectedIndex = 0; return; } lt.toast.error('Error updating status: ' + error.message); // Reset to current status statusSelect.selectedIndex = 0; @@ -938,6 +938,8 @@ function handleFileUpload(files) { if (xhr.status === 200 || xhr.status === 201) { try { const response = JSON.parse(xhr.responseText); + // Keep the CSRF token in sync if the server rotated it + if (response.csrf_token) window.CSRF_TOKEN = response.csrf_token; if (response.success) { if (uploadedCount === totalFiles) { lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000); @@ -968,6 +970,9 @@ function handleFileUpload(files) { }); xhr.open('POST', '/api/upload_attachment.php'); + // Send CSRF via header to match the rest of the app (endpoint accepts both + // the X-CSRF-Token header and the csrf_token form field). + if (window.CSRF_TOKEN) xhr.setRequestHeader('X-CSRF-Token', window.CSRF_TOKEN); xhr.send(formData); }); } @@ -1142,12 +1147,17 @@ function handleMentionInput(e) { const text = textarea.value; const cursorPos = textarea.selectionStart; - // Find @ symbol before cursor + // Find @ symbol before cursor. Only trigger when the @ is at a word boundary + // (start of input or preceded by whitespace) so it does not fire inside email + // addresses like foo@bar. let atPos = -1; for (let i = cursorPos - 1; i >= 0; i--) { const char = text[i]; if (char === '@') { - atPos = i; + const prev = i > 0 ? text[i - 1] : ''; + if (i === 0 || /\s/.test(prev)) { + atPos = i; + } break; } if (char === ' ' || char === '\n') { @@ -1277,20 +1287,27 @@ function selectMention(username) { } /** - * Highlight mentions in comment text + * Highlight mentions in comment text. + * Skips content inside existing anchor tags so URLs/emails that contain '@' + * (e.g. auto-linked links or mailto:) are not corrupted or nested. */ function highlightMentions(text) { - return text.replace(/@([a-zA-Z0-9_-]+)/g, '$1'); + return text.replace(/]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) { + if (m.charAt(0) === '<') return m; // leave anchor tags untouched + return '' + m.slice(1) + ''; + }); } // Initialize mention autocomplete when DOM is ready document.addEventListener('DOMContentLoaded', function() { initMentionAutocomplete(); - // Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements) + // Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements). + // Idempotency guard: only process each element once so re-runs don't nest spans. document.querySelectorAll('.comment-text').forEach(el => { - if (!el.hasAttribute('data-markdown')) { + if (!el.hasAttribute('data-markdown') && !el.dataset.mentionsProcessed) { el.innerHTML = highlightMentions(el.innerHTML); + el.dataset.mentionsProcessed = '1'; } });