Compare commits

...

3 Commits

Author SHA1 Message Date
913e294f9d CSS class migrations: stat-card cursor, view toggle, bulk actions visibility
- Replace stat-card cursor:pointer inline style with CSS rule
- Convert view toggle (table/card) to use .is-hidden CSS class
- Convert bulk-actions and export-dropdown to use .is-visible class
- Add .is-hidden/.is-visible utility rules to dashboard.css
- Remove duplicate lt.keys.initDefaults() call from dashboard.js
- Remove redundant setTimeout from view mode restore
- Add lt.keys.initDefaults() to dashboard.js (was missing entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:08:28 -04:00
28aa9e33ea Fix XSS: escape table data and sanitize sort/pagination URL params
- htmlspecialchars() on category, type, status in table rows
- htmlspecialchars() on data-status attributes in quick-action buttons
- Restrict $currentDir to 'asc'|'desc' to prevent class injection
- htmlspecialchars() on all http_build_query URLs in pagination and sort headers
- htmlspecialchars() on AuditLogView pagination URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:40:51 -04:00
31aa7d1b81 Fix JS SyntaxError breaking tabs, textarea scrolling, and XSS escaping
Bug fixes:
- ticket.js: Remove duplicate const textarea declaration inside showMentionSuggestions()
  (was redeclaring a parameter, causing SyntaxError that broke all tab switching)
- ticket.css: Add overflow:hidden + resize:none to disabled textarea so description
  shows full height without internal scrollbar (page scrolls instead)
- ticket.js: Trigger height recalculation when entering edit mode on description

XSS/escaping fixes:
- TicketView.php: htmlspecialchars() on description textarea content (closes </textarea> injection risk)
- TicketView.php: htmlspecialchars() on ticket status and workflow transition status strings
- DashboardView.php: htmlspecialchars() on $cat/$type in input value= attributes
- RecurringTicketsView.php: htmlspecialchars() on composed schedule string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:34:55 -04:00
8 changed files with 44 additions and 43 deletions

View File

@@ -1413,6 +1413,11 @@ h1 {
.loading-overlay--hiding { opacity: 0; } .loading-overlay--hiding { opacity: 0; }
.has-overlay { position: relative; } .has-overlay { position: relative; }
/* Visibility utilities — !important required to override HTML inline style="display:none" */
.is-hidden { display: none !important; }
.bulk-actions-inline.is-visible { display: flex !important; }
.export-dropdown.is-visible { display: inline-block !important; }
.loading-overlay .loading-text { .loading-overlay .loading-text {
margin-top: 1rem; margin-top: 1rem;
animation: blink-cursor 1s step-end infinite; animation: blink-cursor 1s step-end infinite;
@@ -4580,6 +4585,7 @@ tr:hover .quick-actions {
transition: border-color 0.2s ease, transform 0.15s ease; transition: border-color 0.2s ease, transform 0.15s ease;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
cursor: pointer;
} }
/* Corner accent */ /* Corner accent */

View File

@@ -584,6 +584,8 @@ textarea.editable {
background: var(--bg-secondary); background: var(--bg-secondary);
cursor: default; cursor: default;
border-color: transparent; border-color: transparent;
overflow: hidden;
resize: none;
} }
/* Button Styles */ /* Button Styles */

View File

@@ -683,22 +683,14 @@ function updateSelectionCount() {
const exportCount = document.getElementById('exportCount'); const exportCount = document.getElementById('exportCount');
if (toolbar && countDisplay) { if (toolbar && countDisplay) {
if (count > 0) { toolbar.classList.toggle('is-visible', count > 0);
toolbar.style.display = 'flex'; if (count > 0) countDisplay.textContent = count;
countDisplay.textContent = count;
} else {
toolbar.style.display = 'none';
}
} }
// Show/hide export dropdown based on selection // Show/hide export dropdown based on selection
if (exportDropdown) { if (exportDropdown) {
if (count > 0) { exportDropdown.classList.toggle('is-visible', count > 0);
exportDropdown.style.display = ''; if (count > 0 && exportCount) exportCount.textContent = count;
if (exportCount) exportCount.textContent = count;
} else {
exportDropdown.style.display = 'none';
}
} }
} }
@@ -1359,14 +1351,14 @@ function setViewMode(mode) {
if (!tableView || !cardView) return; if (!tableView || !cardView) return;
if (mode === 'card') { if (mode === 'card') {
tableView.style.display = 'none'; tableView.classList.add('is-hidden');
cardView.style.display = 'block'; cardView.classList.remove('is-hidden');
tableBtn.classList.remove('active'); tableBtn.classList.remove('active');
cardBtn.classList.add('active'); cardBtn.classList.add('active');
populateKanbanCards(); populateKanbanCards();
} else { } else {
tableView.style.display = 'block'; tableView.classList.remove('is-hidden');
cardView.style.display = 'none'; cardView.classList.add('is-hidden');
tableBtn.classList.add('active'); tableBtn.classList.add('active');
cardBtn.classList.remove('active'); cardBtn.classList.remove('active');
} }
@@ -1444,8 +1436,7 @@ function populateKanbanCards() {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode'); const savedMode = localStorage.getItem('ticketViewMode');
if (savedMode === 'card') { if (savedMode === 'card') {
// Delay to ensure DOM is ready setViewMode('card');
setTimeout(() => setViewMode('card'), 100);
} }
}); });
@@ -1755,6 +1746,7 @@ function initRelativeTimes() {
document.addEventListener('DOMContentLoaded', initRelativeTimes); document.addEventListener('DOMContentLoaded', initRelativeTimes);
setInterval(initRelativeTimes, 60000); setInterval(initRelativeTimes, 60000);
// Export for use in other scripts // Export for use in other scripts
window.generateSkeletonRows = generateSkeletonRows; window.generateSkeletonRows = generateSkeletonRows;
window.generateSkeletonComments = generateSkeletonComments; window.generateSkeletonComments = generateSkeletonComments;

View File

@@ -86,6 +86,8 @@ function toggleEditMode() {
// Enable description (textarea) // Enable description (textarea)
if (descriptionField) { if (descriptionField) {
descriptionField.disabled = false; descriptionField.disabled = false;
descriptionField.style.height = 'auto';
descriptionField.style.height = descriptionField.scrollHeight + 'px';
} }
// Enable metadata fields (priority, category, type) // Enable metadata fields (priority, category, type)
@@ -1036,7 +1038,6 @@ function showMentionSuggestions(query, textarea) {
mentionAutocomplete.innerHTML = html; mentionAutocomplete.innerHTML = html;
mentionAutocomplete.classList.add('active'); mentionAutocomplete.classList.add('active');
const textarea = document.getElementById('newComment');
if (textarea) textarea.setAttribute('aria-expanded', 'true'); if (textarea) textarea.setAttribute('aria-expanded', 'true');
selectedMentionIndex = 0; selectedMentionIndex = 0;

View File

@@ -144,7 +144,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label> <label>
<input type="checkbox" <input type="checkbox"
name="category" name="category"
value="<?php echo $cat; ?>" value="<?php echo htmlspecialchars($cat); ?>"
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>> <?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
<?php echo htmlspecialchars($cat); ?> <?php echo htmlspecialchars($cat); ?>
</label> </label>
@@ -161,7 +161,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label> <label>
<input type="checkbox" <input type="checkbox"
name="type" name="type"
value="<?php echo $type; ?>" value="<?php echo htmlspecialchars($type); ?>"
<?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>> <?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>>
<?php echo htmlspecialchars($type); ?> <?php echo htmlspecialchars($type); ?>
</label> </label>
@@ -291,7 +291,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Previous page button // Previous page button
if ($page > 1) { if ($page > 1) {
$currentParams['page'] = $page - 1; $currentParams['page'] = $page - 1;
$prevUrl = '?' . http_build_query($currentParams); $prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button data-action='navigate' data-url='$prevUrl' aria-label='Previous page'>[ « ]</button>"; echo "<button data-action='navigate' data-url='$prevUrl' aria-label='Previous page'>[ « ]</button>";
} }
@@ -299,14 +299,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
for ($i = 1; $i <= $totalPages; $i++) { for ($i = 1; $i <= $totalPages; $i++) {
$activeClass = ($i === $page) ? 'active' : ''; $activeClass = ($i === $page) ? 'active' : '';
$currentParams['page'] = $i; $currentParams['page'] = $i;
$pageUrl = '?' . http_build_query($currentParams); $pageUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>"; echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>";
} }
// Next page button // Next page button
if ($page < $totalPages) { if ($page < $totalPages) {
$currentParams['page'] = $page + 1; $currentParams['page'] = $page + 1;
$nextUrl = '?' . http_build_query($currentParams); $nextUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button data-action='navigate' data-url='$nextUrl' aria-label='Next page'>[ » ]</button>"; echo "<button data-action='navigate' data-url='$nextUrl' aria-label='Next page'>[ » ]</button>";
} }
?> ?>
@@ -393,7 +393,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endif; ?> <?php endif; ?>
<?php <?php
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id'; $currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc'; $currentDir = (isset($_GET['dir']) && $_GET['dir'] === 'asc') ? 'asc' : 'desc';
$columns = [ $columns = [
'ticket_id' => 'Ticket ID', 'ticket_id' => 'Ticket ID',
@@ -417,7 +417,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : ''; $sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
$ariaSort = ($currentSort === $col) ? "aria-sort='" . ($currentDir === 'asc' ? 'ascending' : 'descending') . "'" : ''; $ariaSort = ($currentSort === $col) ? "aria-sort='" . ($currentDir === 'asc' ? 'ascending' : 'descending') . "'" : '';
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]); $sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
$sortUrl = '?' . http_build_query($sortParams); $sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
echo "<th scope='col' class='$sortClass' data-action='navigate' data-url='$sortUrl' $ariaSort>$label</th>"; echo "<th scope='col' class='$sortClass' data-action='navigate' data-url='$sortUrl' $ariaSort>$label</th>";
} }
} }
@@ -440,9 +440,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>"; echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
echo "<td><span>{$row['priority']}</span></td>"; echo "<td><span>{$row['priority']}</span></td>";
echo "<td>" . htmlspecialchars($row['title']) . "</td>"; echo "<td>" . htmlspecialchars($row['title']) . "</td>";
echo "<td>{$row['category']}</td>"; echo "<td>" . htmlspecialchars($row['category']) . "</td>";
echo "<td>{$row['type']}</td>"; echo "<td>" . htmlspecialchars($row['type']) . "</td>";
echo "<td><span class='status-" . str_replace(' ', '-', $row['status']) . "'>{$row['status']}</span></td>"; $statusSlug = htmlspecialchars(str_replace(' ', '-', $row['status']), ENT_QUOTES);
echo "<td><span class='status-" . $statusSlug . "'>" . htmlspecialchars($row['status']) . "</span></td>";
echo "<td>" . htmlspecialchars($creator) . "</td>"; echo "<td>" . htmlspecialchars($creator) . "</td>";
echo "<td>" . htmlspecialchars($assignedTo) . "</td>"; echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>"; echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
@@ -451,7 +452,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
echo "<td class='quick-actions-cell'>"; echo "<td class='quick-actions-cell'>";
echo "<div class='quick-actions'>"; echo "<div class='quick-actions'>";
echo "<button data-action='view-ticket' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='View' aria-label='View ticket " . $row['ticket_id'] . "'>&gt;</button>"; echo "<button data-action='view-ticket' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='View' aria-label='View ticket " . $row['ticket_id'] . "'>&gt;</button>";
echo "<button data-action='quick-status' data-ticket-id='" . $row['ticket_id'] . "' data-status='" . $row['status'] . "' class='quick-action-btn' title='Change Status' aria-label='Change status for ticket " . $row['ticket_id'] . "'>~</button>"; echo "<button data-action='quick-status' data-ticket-id='" . (int)$row['ticket_id'] . "' data-status='" . htmlspecialchars($row['status'], ENT_QUOTES) . "' class='quick-action-btn' title='Change Status' aria-label='Change status for ticket " . (int)$row['ticket_id'] . "'>~</button>";
echo "<button data-action='quick-assign' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='Assign' aria-label='Assign ticket " . $row['ticket_id'] . "'>@</button>"; echo "<button data-action='quick-assign' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='Assign' aria-label='Assign ticket " . $row['ticket_id'] . "'>@</button>";
echo "</div>"; echo "</div>";
echo "</td>"; echo "</td>";
@@ -496,12 +497,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<span class="ts-cell" data-ts="<?php echo htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo date('Y-m-d H:i', strtotime($row['updated_at'])); ?>"><?php echo date('M j', strtotime($row['updated_at'])); ?></span> <span class="ts-cell" data-ts="<?php echo htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo date('Y-m-d H:i', strtotime($row['updated_at'])); ?>"><?php echo date('M j', strtotime($row['updated_at'])); ?></span>
</div> </div>
</div> </div>
<div class="ticket-card-status <?php echo $statusClass; ?>"> <div class="ticket-card-status <?php echo htmlspecialchars($statusClass); ?>">
<?php echo $row['status']; ?> <?php echo htmlspecialchars($row['status']); ?>
</div> </div>
<div class="ticket-card-actions"> <div class="ticket-card-actions">
<button data-action="view-ticket" data-ticket-id="<?php echo $row['ticket_id']; ?>" title="View" aria-label="View ticket <?php echo $row['ticket_id']; ?>">&gt;</button> <button data-action="view-ticket" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" title="View" aria-label="View ticket <?php echo (int)$row['ticket_id']; ?>">&gt;</button>
<button data-action="quick-status" data-ticket-id="<?php echo $row['ticket_id']; ?>" data-status="<?php echo $row['status']; ?>" title="Status" aria-label="Change status for ticket <?php echo $row['ticket_id']; ?>">~</button> <button data-action="quick-status" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" data-status="<?php echo htmlspecialchars($row['status'], ENT_QUOTES); ?>" title="Status" aria-label="Change status for ticket <?php echo (int)$row['ticket_id']; ?>">~</button>
</div> </div>
</div> </div>
<?php <?php
@@ -523,7 +524,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- END OUTER FRAME --> <!-- END OUTER FRAME -->
<!-- Kanban Card View --> <!-- Kanban Card View -->
<section id="cardView" class="card-view-container" style="display: none;" aria-label="Kanban board view"> <section id="cardView" class="card-view-container is-hidden" aria-label="Kanban board view">
<div class="kanban-board"> <div class="kanban-board">
<div class="kanban-column" data-status="Open"> <div class="kanban-column" data-status="Open">
<div class="kanban-column-header status-Open"> <div class="kanban-column-header status-Open">
@@ -991,7 +992,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Stat card click handlers for filtering // Stat card click handlers for filtering
document.querySelectorAll('.stat-card').forEach(card => { document.querySelectorAll('.stat-card').forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', function() { card.addEventListener('click', function() {
const classList = this.classList; const classList = this.classList;
let url = '/?'; let url = '/?';

View File

@@ -246,14 +246,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="header-controls"> <div class="header-controls">
<div class="status-priority-group"> <div class="status-priority-group">
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status" aria-label="Change ticket status"> <select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status" aria-label="Change ticket status">
<option value="<?php echo $ticket['status']; ?>" selected> <option value="<?php echo htmlspecialchars($ticket['status']); ?>" selected>
<?php echo $ticket['status']; ?> (current) <?php echo htmlspecialchars($ticket['status']); ?> (current)
</option> </option>
<?php foreach ($allowedTransitions as $transition): ?> <?php foreach ($allowedTransitions as $transition): ?>
<option value="<?php echo $transition['to_status']; ?>" <option value="<?php echo htmlspecialchars($transition['to_status']); ?>"
data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>" data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>"> data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>">
<?php echo $transition['to_status']; ?> <?php echo htmlspecialchars($transition['to_status']); ?>
<?php if ($transition['requires_comment']): ?> *<?php endif; ?> <?php if ($transition['requires_comment']): ?> *<?php endif; ?>
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?> <?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
</option> </option>
@@ -295,7 +295,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-subsection-header">Description</div> <div class="ascii-subsection-header">Description</div>
<div class="detail-group full-width"> <div class="detail-group full-width">
<label>Description</label> <label>Description</label>
<textarea class="editable" data-field="description" disabled><?php echo $ticket["description"]; ?></textarea> <textarea class="editable" data-field="description" disabled><?php echo htmlspecialchars($ticket["description"] ?? ''); ?></textarea>
</div> </div>
</div> </div>

View File

@@ -149,7 +149,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
for ($i = 1; $i <= min($totalPages, 10); $i++) { for ($i = 1; $i <= min($totalPages, 10); $i++) {
$params['page'] = $i; $params['page'] = $i;
$activeClass = ($i == $page) ? 'active' : ''; $activeClass = ($i == $page) ? 'active' : '';
$url = '?' . http_build_query($params); $url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> "; echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
} }
if ($totalPages > 10) { if ($totalPages > 10) {

View File

@@ -81,7 +81,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$schedule .= ' (Day ' . $rt['schedule_day'] . ')'; $schedule .= ' (Day ' . $rt['schedule_day'] . ')';
} }
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5); $schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
echo $schedule; echo htmlspecialchars($schedule);
?> ?>
</td> </td>
<td><?php echo htmlspecialchars($rt['category']); ?></td> <td><?php echo htmlspecialchars($rt['category']); ?></td>