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
+18 -11
View File
@@ -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();
});
}