3 Commits

Author SHA1 Message Date
jared 67a7d769f0 fix: unassigned filter not working + null guards on modal selects
- DashboardController: handle assigned_to='unassigned' before validateUserId()
  which discarded the string, causing the filter to never reach TicketModel;
  model already correctly converts 'unassigned' to IS NULL in SQL
- dashboard.js: add null guards before .value access on dynamically-created
  modal selects (bulkPriority, bulkStatus, quickStatusSelect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:35:04 -04:00
jared 84b104a501 fix: various inline style cleanup, a11y improvements, and bind_param bug
- Replace style="text-align:center" with .lt-text-center utility class in
  WorkflowDesignerView, CustomFieldsView, error_403, error_404, DashboardView JS string
- Replace style="margin-top:..." with .lt-mt-sm utility in WorkflowDesignerView
- Switch comment-edit-raw data-store textareas to .is-hidden class (TicketView PHP
  + JS-rendered; ticket.js template literal) — these are never shown, only read via .value
- Add aria-describedby="visibilityGroupsHint" + id on hint <p> in CreateTicketView
- Fix bind_param type string bug in manage_workflows.php PUT handler: 'ssiiiii' → 'ssiiii'
  (7 type chars for 6 params caused binding error on workflow transition updates)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:29:52 -04:00
jared ff109a710c fix: remove CSP-blocked inline event handlers (onerror, onclick)
- Remove all onerror="this.style.display='none'" from avatar imgs in
  layout_header.php, DashboardView.php, and TicketView.php (PHP + JS)
- Replace onclick SLA dismiss with data-action="dismiss-priority-banner"
  attribute; handler wired via existing click delegation in TicketView.php
- Global capture-phase error delegation in layout_footer.php handles all
  avatar image failures by adding .lt-avatar-img-err class (CSS display:none)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:15:45 -04:00
12 changed files with 45 additions and 35 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ try {
$stmt = $conn->prepare("UPDATE status_transitions SET $stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ? from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?"); WHERE transition_id = ?");
$stmt->bind_param('ssiiiii', $stmt->bind_param('ssiiii',
$data['from_status'], $data['from_status'],
$data['to_status'], $data['to_status'],
$data['requires_comment'] ?? 0, $data['requires_comment'] ?? 0,
+9 -3
View File
@@ -686,7 +686,9 @@ function closeBulkPriorityModal() {
} }
function performBulkPriority() { function performBulkPriority() {
const priority = document.getElementById('bulkPriority').value; const priorityEl = document.getElementById('bulkPriority');
if (!priorityEl) return;
const priority = priorityEl.value;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
if (!priority) { if (!priority) {
@@ -789,7 +791,9 @@ function closeBulkStatusModal() {
} }
function performBulkStatusChange() { function performBulkStatusChange() {
const status = document.getElementById('bulkStatus').value; const bulkStatusEl = document.getElementById('bulkStatus');
if (!bulkStatusEl) return;
const status = bulkStatusEl.value;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
if (!status) { if (!status) {
@@ -986,7 +990,9 @@ function closeQuickStatusModal() {
} }
function performQuickStatusChange(ticketId) { function performQuickStatusChange(ticketId) {
const newStatus = document.getElementById('quickStatusSelect').value; const quickStatusEl = document.getElementById('quickStatusSelect');
if (!quickStatusEl) return;
const newStatus = quickStatusEl.value;
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => { .then(data => {
+1 -1
View File
@@ -1528,7 +1528,7 @@ function submitReply(parentCommentId) {
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}> <div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
${displayText} ${displayText}
</div> </div>
<textarea class="comment-edit-raw" id="comment-raw-${data.comment_id}" style="display:none;">${commentText.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</textarea> <textarea class="comment-edit-raw is-hidden" id="comment-raw-${data.comment_id}">${commentText.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</textarea>
</div> </div>
`; `;
+9 -3
View File
@@ -136,10 +136,16 @@ class DashboardController {
// Validate user ID filters // Validate user ID filters
$createdBy = $this->validateUserId($_GET['created_by'] ?? null); $createdBy = $this->validateUserId($_GET['created_by'] ?? null);
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
if ($createdBy !== null) $filters['created_by'] = $createdBy; if ($createdBy !== null) $filters['created_by'] = $createdBy;
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
// assigned_to accepts a numeric user ID or the special string 'unassigned'
$assignedToRaw = $_GET['assigned_to'] ?? null;
if ($assignedToRaw === 'unassigned') {
$filters['assigned_to'] = 'unassigned';
} else {
$assignedTo = $this->validateUserId($assignedToRaw);
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
}
// Get tickets with pagination, sorting, search, and advanced filters // Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []); $result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
+2 -2
View File
@@ -185,7 +185,7 @@ include __DIR__ . '/layout_header.php';
<p id="visibilityHint" class="lt-form-hint">Everyone who is logged in can view this ticket.</p> <p id="visibilityHint" class="lt-form-hint">Everyone who is logged in can view this ticket.</p>
</div> </div>
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite"> <div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite" aria-describedby="visibilityGroupsHint">
<label class="lt-label lt-text-cyan">Allowed Groups</label> <label class="lt-label lt-text-cyan">Allowed Groups</label>
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm"> <div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php <?php
@@ -205,7 +205,7 @@ include __DIR__ . '/layout_header.php';
<span class="lt-text-muted lt-text-sm">No groups available</span> <span class="lt-text-muted lt-text-sm">No groups available</span>
<?php endif ?> <?php endif ?>
</div> </div>
<p class="lt-form-hint lt-form-hint--warn">Select at least one group for Internal visibility.</p> <p id="visibilityGroupsHint" class="lt-form-hint lt-form-hint--warn">Select at least one group for Internal visibility.</p>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -331,7 +331,7 @@ include __DIR__ . '/layout_header.php';
<div class="workload-item"> <div class="workload-item">
<div class="lt-avatar lt-avatar--sm <?= $avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($name) ?>"> <div class="lt-avatar lt-avatar--sm <?= $avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($name) ?>">
<?php if ($userId > 0): ?> <?php if ($userId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $userId ?>" alt="" class="lt-avatar-img" onerror="this.style.display='none'"> <img src="/api/user_avatar.php?user_id=<?= $userId ?>" alt="" class="lt-avatar-img">
<?php endif ?> <?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span> <span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div> </div>
@@ -1269,7 +1269,7 @@ if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
(age ? '<div class="lt-kv-row"><span class="lt-kv-label">Age</span><span class="lt-kv-value">' + esc(age) + '</span></div>' : '') + (age ? '<div class="lt-kv-row"><span class="lt-kv-label">Age</span><span class="lt-kv-value">' + esc(age) + '</span></div>' : '') +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<p class="lt-text-muted lt-text-xs" style="text-align:center">Click "Open Full Ticket" for description, comments &amp; attachments.</p>'; '<p class="lt-text-muted lt-text-xs lt-text-center">Click "Open Full Ticket" for description, comments &amp; attachments.</p>';
lt.rightDrawer.open('ticketPreviewDrawer'); lt.rightDrawer.open('ticketPreviewDrawer');
} }
+12 -13
View File
@@ -212,12 +212,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
</div> </div>
</div> </div>
</div> </div>
<button type="button" class="lt-alert-close" aria-label="Dismiss" <button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">&#x2715;</button>
onclick="(function(btn){
var id=btn.closest('[data-alert-id]').dataset.alertId;
try{sessionStorage.setItem('lt_dismissed_'+id,'1');}catch(e){}
btn.closest('.lt-alert').classList.add('dismissed');
})(this)">&#x2715;</button>
</div> </div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>"> <script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){ (function(){
@@ -564,8 +559,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<?php if ($commentUserId > 0): ?> <?php if ($commentUserId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $commentUserId ?>" <img src="/api/user_avatar.php?user_id=<?= $commentUserId ?>"
alt="" alt=""
class="lt-avatar-img" class="lt-avatar-img">
onerror="this.style.display='none'">
<?php endif ?> <?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span> <span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div> </div>
@@ -602,9 +596,8 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
? htmlspecialchars($comment['comment_text']) ? htmlspecialchars($comment['comment_text'])
: nl2br(htmlspecialchars($comment['comment_text'])) ?> : nl2br(htmlspecialchars($comment['comment_text'])) ?>
</div> </div>
<textarea class="lt-input lt-textarea comment-edit-raw" <textarea class="lt-input lt-textarea comment-edit-raw is-hidden"
id="comment-raw-<?= $commentId ?>" id="comment-raw-<?= $commentId ?>"
style="display:none"
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea> aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
</div> </div>
<?php if (!empty($comment['replies'])): ?> <?php if (!empty($comment['replies'])): ?>
@@ -978,7 +971,7 @@ document.addEventListener('DOMContentLoaded', function () {
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0; for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
var color = avatarColors[Math.abs(hash) % 4]; var color = avatarColors[Math.abs(hash) % 4];
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' + html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img" onerror="this.style.display=\'none\'">' + '<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' + '<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>'; '</div>';
}); });
@@ -1121,6 +1114,12 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof editComment === 'function') editComment(parseInt(commentId, 10)); if (typeof editComment === 'function') editComment(parseInt(commentId, 10));
} else if (action === 'delete-comment' && commentId) { } else if (action === 'delete-comment' && commentId) {
if (typeof deleteComment === 'function') deleteComment(parseInt(commentId, 10)); if (typeof deleteComment === 'function') deleteComment(parseInt(commentId, 10));
} else if (action === 'dismiss-priority-banner') {
var banner = target.closest('[data-alert-id]');
if (banner) {
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
banner.classList.add('dismissed');
}
} }
}); });
@@ -1237,7 +1236,7 @@ document.addEventListener('DOMContentLoaded', function () {
' data-action="delete-comment" data-comment-id="' + commentId + '" aria-label="Delete comment">Del</button>' : ''; ' data-action="delete-comment" data-comment-id="' + commentId + '" aria-label="Delete comment">Del</button>' : '';
var threadLine = parentId ? '<div class="thread-line" aria-hidden="true"></div>' : ''; var threadLine = parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var avatarImg = userId > 0 ? var avatarImg = userId > 0 ?
'<img src="/api/user_avatar.php?user_id=' + userId + '" alt="" class="lt-avatar-img" onerror="this.style.display=\'none\'">' : ''; '<img src="/api/user_avatar.php?user_id=' + userId + '" alt="" class="lt-avatar-img">' : '';
var div = document.createElement('div'); var div = document.createElement('div');
div.className = 'comment ' + depthClass + ' ' + threadClass; div.className = 'comment ' + depthClass + ' ' + threadClass;
@@ -1263,7 +1262,7 @@ document.addEventListener('DOMContentLoaded', function () {
'<div class="comment-text" id="comment-text-' + commentId + '"' + (mdEnabled ? ' data-markdown data-rendered="1"' : '') + '>' + '<div class="comment-text" id="comment-text-' + commentId + '"' + (mdEnabled ? ' data-markdown data-rendered="1"' : '') + '>' +
commentText + commentText +
'</div>' + '</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw" id="comment-raw-' + commentId + '" style="display:none" aria-hidden="true">' + '<textarea class="lt-input lt-textarea comment-edit-raw is-hidden" id="comment-raw-' + commentId + '" aria-hidden="true">' +
escapedRaw + escapedRaw +
'</textarea>' + '</textarea>' +
'</div>'; '</div>';
+1 -1
View File
@@ -50,7 +50,7 @@ include __DIR__ . '/../../views/layout_header.php';
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td> <td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
<td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td> <td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td> <td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
<td data-label="Required" style="text-align:center"> <td data-label="Required" class="lt-text-center">
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?> <?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td> </td>
<td data-label="Status"> <td data-label="Status">
+6 -6
View File
@@ -31,13 +31,13 @@ include __DIR__ . '/../../views/layout_header.php';
$toCount = 0; $toCount = 0;
if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } } if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } }
?> ?>
<div class="lt-card" style="text-align:center"> <div class="lt-card lt-text-center">
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span> <span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
<div class="lt-text-xs lt-text-muted lt-mt-sm">→ <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div> <div class="lt-text-xs lt-text-muted lt-mt-sm">→ <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
</div> </div>
<?php endforeach ?> <?php endforeach ?>
</div> </div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem"> <p class="lt-text-xs lt-text-muted lt-mt-sm">
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets. Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
</p> </p>
</div> </div>
@@ -69,17 +69,17 @@ include __DIR__ . '/../../views/layout_header.php';
<td data-label="From"> <td data-label="From">
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span> <span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
</td> </td>
<td class="lt-text-amber lt-text-xs" style="text-align:center">&rarr;</td> <td class="lt-text-amber lt-text-xs lt-text-center">&rarr;</td>
<td data-label="To"> <td data-label="To">
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span> <span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
</td> </td>
<td data-label="Req. Comment" style="text-align:center"> <td data-label="Req. Comment" class="lt-text-center">
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?> <?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td> </td>
<td data-label="Req. Admin" style="text-align:center"> <td data-label="Req. Admin" class="lt-text-center">
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?> <?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td> </td>
<td data-label="Active" style="text-align:center"> <td data-label="Active" class="lt-text-center">
<?= $wf['is_active'] <?= $wf['is_active']
? '<span class="lt-text-cyan">✓</span>' ? '<span class="lt-text-cyan">✓</span>'
: '<span class="lt-text-danger">✗</span>' ?> : '<span class="lt-text-danger">✗</span>' ?>
+1 -1
View File
@@ -10,7 +10,7 @@ include __DIR__ . '/../views/layout_header.php';
<div class="lt-frame" style="max-width:32rem;margin:4rem auto"> <div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span> <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-danger">[ 403 ] ACCESS DENIED</div> <div class="lt-section-header lt-text-danger">[ 403 ] ACCESS DENIED</div>
<div class="lt-section-body" style="text-align:center"> <div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">You do not have permission to access this resource.</p> <p class="lt-text-muted lt-mb-md">You do not have permission to access this resource.</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a> <a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div> </div>
+1 -1
View File
@@ -10,7 +10,7 @@ include __DIR__ . '/../views/layout_header.php';
<div class="lt-frame" style="max-width:32rem;margin:4rem auto"> <div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span> <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-amber">[ 404 ] NOT FOUND</div> <div class="lt-section-header lt-text-amber">[ 404 ] NOT FOUND</div>
<div class="lt-section-body" style="text-align:center"> <div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">The page you requested does not exist.</p> <p class="lt-text-muted lt-mb-md">The page you requested does not exist.</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a> <a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div> </div>
-1
View File
@@ -166,7 +166,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>" <img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
alt="" alt=""
class="lt-avatar-img" class="lt-avatar-img"
onerror="this.style.display='none'">
<?php endif ?> <?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span> <span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
</div> </div>