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:
+40
-23
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.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';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user