Fix frontend JS: CSRF resync, status-comment flow, markdown/XSS, kanban

- base.js lt.api: resync window.CSRF_TOKEN from response bodies before
  throwing on errors and attach err.data/err.status, so a desynced client
  auto-recovers without a reload
- add lt.ticketStatus.submit: status changes that require a comment now
  prompt, post the comment, and retry update_ticket with it; wired into
  the ticket dropdown, dashboard quick-status, kanban drag-drop and the
  1-4 keyboard shortcuts (bulk ops unchanged) — matches the new server
  requires_comment enforcement
- base.js markdown.render: drop the unsafe marked/markdownit delegation;
  always use the built-in XSS-safe renderer
- ticket.js: XHR upload sends the X-CSRF-Token header and resyncs the
  token; use lt.escHtml instead of a re-inlined escape chain; @-mention
  trigger requires a word boundary (no firing inside emails); idempotent,
  anchor-safe highlightMentions
- base.js typeahead: discard out-of-order async results
- markdown.js: balanced table tbody/thead; ticket-ref linkification runs
  after code extraction so #ids inside code aren't linked
- dashboard.js kanban: don't swallow the click after a drag
- keyboard-shortcuts.js: J/K skip hidden/skeleton rows; drop duplicate ?

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:50:27 -04:00
co-authored by Claude Opus 4.8
parent d11cb989bf
commit 113b7f9d3f
5 changed files with 200 additions and 56 deletions
+40 -23
View File
@@ -291,14 +291,8 @@ function addComment() {
// For markdown, use parseMarkdown (sanitizes HTML)
displayText = parseMarkdown(commentText);
} else {
// For non-markdown, convert line breaks to <br> and escape HTML
displayText = commentText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>');
// For non-markdown, escape HTML then convert line breaks to <br>
displayText = lt.escHtml(commentText).replace(/\n/g, '<br>');
}
// 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, '<span class="mention">$1</span>');
return text.replace(/<a\b[^>]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) {
if (m.charAt(0) === '<') return m; // leave anchor tags untouched
return '<span class="mention">' + m.slice(1) + '</span>';
});
}
// 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';
}
});