Compare commits
7 Commits
54887ffa24
...
ac05b212b2
| Author | SHA1 | Date | |
|---|---|---|---|
| ac05b212b2 | |||
| df6c4de196 | |||
| 2ccf4f2261 | |||
| dcbe6fb383 | |||
| 914c33ecf3 | |||
| d588590989 | |||
| b7b6884bb0 |
+52
-15
@@ -65,27 +65,57 @@ $assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
// Query 2: Comments on tickets I own or watch (events from other users)
|
// Query 2: Comments on tickets I own or watch (events from other users)
|
||||||
$commentSql = "SELECT DISTINCT
|
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
|
||||||
|
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
|
||||||
|
|
||||||
|
// Step A: ticket IDs the current user owns or watches
|
||||||
|
$myTicketIds = [];
|
||||||
|
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
|
||||||
|
$stmt = $conn->prepare($myTicketsSql);
|
||||||
|
$stmt->bind_param('ii', $userId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$mtResult = $stmt->get_result();
|
||||||
|
while ($mtRow = $mtResult->fetch_assoc()) { $myTicketIds[(int)$mtRow['ticket_id']] = true; }
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
||||||
|
$stmt = $conn->prepare($watchedSql);
|
||||||
|
$stmt->bind_param('i', $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$wResult = $stmt->get_result();
|
||||||
|
while ($wRow = $wResult->fetch_assoc()) { $myTicketIds[(int)$wRow['ticket_id']] = true; }
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step B: fetch recent comment audit events not by the current user
|
||||||
|
$commentSql = "SELECT
|
||||||
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
INNER JOIN tickets t ON t.ticket_id = CAST(al.entity_id AS UNSIGNED)
|
WHERE al.action_type = 'create'
|
||||||
LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ?
|
AND al.entity_type = 'comment'
|
||||||
WHERE al.action_type = 'comment'
|
|
||||||
AND al.entity_type = 'ticket'
|
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND (t.assigned_to = ? OR t.created_by = ? OR tw.user_id IS NOT NULL)
|
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 15";
|
LIMIT 50";
|
||||||
|
|
||||||
$stmt = $conn->prepare($commentSql);
|
$stmt = $conn->prepare($commentSql);
|
||||||
$stmt->bind_param('iiii', $userId, $userId, $userId, $userId);
|
$stmt->bind_param('i', $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$commentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step C: filter to only comments on tickets the current user owns/watches
|
||||||
|
$commentRows = [];
|
||||||
|
foreach ($rawCommentRows as $rawRow) {
|
||||||
|
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
|
||||||
|
$tid = (int)($d['ticket_id'] ?? 0);
|
||||||
|
if ($tid > 0 && isset($myTicketIds[$tid])) {
|
||||||
|
$commentRows[] = $rawRow;
|
||||||
|
if (count($commentRows) >= 15) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Query 3: Status changes on watched tickets (from other users)
|
// Query 3: Status changes on watched tickets (from other users)
|
||||||
$statusSql = "SELECT DISTINCT
|
$statusSql = "SELECT DISTINCT
|
||||||
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
@@ -97,7 +127,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND al.details LIKE '%\"field\":\"status\"%'
|
AND al.details LIKE '%"status":%'
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 10";
|
LIMIT 10";
|
||||||
|
|
||||||
@@ -123,16 +153,23 @@ $all = array_slice($all, 0, 30);
|
|||||||
$notifications = [];
|
$notifications = [];
|
||||||
foreach ($all as $row) {
|
foreach ($all as $row) {
|
||||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
$ticketId = (int)$row['entity_id'];
|
// Comment rows: entity_id is the comment_id; real ticket_id is in details
|
||||||
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
|
? 'comment'
|
||||||
|
: $row['action_type'];
|
||||||
|
$ticketId = ($actionType === 'comment')
|
||||||
|
? (int)($details['ticket_id'] ?? 0)
|
||||||
|
: (int)$row['entity_id'];
|
||||||
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
|
|
||||||
// Build human-readable title
|
// Build human-readable title
|
||||||
$title = match($row['action_type']) {
|
$title = match($actionType) {
|
||||||
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
'update' => (function() use ($row, $details, $ticketId) {
|
'update' => (function() use ($row, $details, $ticketId) {
|
||||||
$from = $details['old_value'] ?? '?';
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
$to = $details['new_value'] ?? '?';
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||||
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
||||||
})(),
|
})(),
|
||||||
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
@@ -149,7 +186,7 @@ foreach ($all as $row) {
|
|||||||
'title' => $title,
|
'title' => $title,
|
||||||
'created_at' => $row['created_at'],
|
'created_at' => $row['created_at'],
|
||||||
'is_read' => $isRead,
|
'is_read' => $isRead,
|
||||||
'action' => $row['action_type'],
|
'action' => $actionType,
|
||||||
'url' => "/ticket/{$ticketId}",
|
'url' => "/ticket/{$ticketId}",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1211,6 +1211,7 @@ select option:checked {
|
|||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
}
|
}
|
||||||
|
.lt-badge-sm { font-size: 0.5rem; padding: 0.05rem 0.3rem; }
|
||||||
.lt-badge-green { color: var(--accent-green); }
|
.lt-badge-green { color: var(--accent-green); }
|
||||||
.lt-badge-amber { color: var(--accent-amber); }
|
.lt-badge-amber { color: var(--accent-amber); }
|
||||||
.lt-badge-red { color: var(--accent-red); }
|
.lt-badge-red { color: var(--accent-red); }
|
||||||
@@ -1316,6 +1317,20 @@ select option:checked {
|
|||||||
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
|
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
|
||||||
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
|
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
|
||||||
|
|
||||||
|
/* Modal size modifiers */
|
||||||
|
.lt-modal-xs { width: min(280px, 92vw); }
|
||||||
|
.lt-modal-sm { width: min(360px, 92vw); }
|
||||||
|
|
||||||
|
/* Modal header danger variant */
|
||||||
|
.lt-modal-header--danger {
|
||||||
|
background: rgba(255, 77, 77, 0.08);
|
||||||
|
border-bottom-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
.lt-modal-header--danger .lt-modal-title {
|
||||||
|
color: var(--accent-red);
|
||||||
|
text-shadow: var(--glow-red);
|
||||||
|
}
|
||||||
|
|
||||||
.lt-modal-body {
|
.lt-modal-body {
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -2326,6 +2341,7 @@ select option:checked {
|
|||||||
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
|
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
|
||||||
.lt-hidden { display: none !important; }
|
.lt-hidden { display: none !important; }
|
||||||
|
.is-hidden { display: none !important; }
|
||||||
|
|
||||||
/* Skip navigation link — visible only on focus */
|
/* Skip navigation link — visible only on focus */
|
||||||
.lt-skip-link {
|
.lt-skip-link {
|
||||||
|
|||||||
@@ -203,8 +203,7 @@ body.edit-mode .editable-metadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Visibility groups toggle ────────────────────────────────── */
|
/* ── Visibility groups toggle ────────────────────────────────── */
|
||||||
.ticket-visibility-groups.is-hidden,
|
.ticket-visibility-groups.is-hidden { display: none !important; }
|
||||||
.is-hidden { display: none !important; }
|
|
||||||
|
|
||||||
/* ── Page header utility ─────────────────────────────────────── */
|
/* ── Page header utility ─────────────────────────────────────── */
|
||||||
.lt-page-header {
|
.lt-page-header {
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function getCurrentFilterCriteria() {
|
|||||||
|
|
||||||
const statusSelect = document.getElementById('adv-status');
|
const statusSelect = document.getElementById('adv-status');
|
||||||
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
||||||
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
|
if (selectedStatuses.length > 0) criteria.status = selectedStatuses; // keep as array — pill handler uses .join(',')
|
||||||
|
|
||||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||||
if (priorityMin) criteria.priority_min = priorityMin;
|
if (priorityMin) criteria.priority_min = priorityMin;
|
||||||
@@ -256,9 +256,11 @@ function applySavedFilterCriteria(criteria) {
|
|||||||
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
||||||
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
||||||
|
|
||||||
// Status
|
// Status — criteria.status may be an array (new saves) or a comma-joined string (old saves)
|
||||||
const statusSelect = document.getElementById('adv-status');
|
const statusSelect = document.getElementById('adv-status');
|
||||||
const statuses = criteria.status ? criteria.status.split(',') : [];
|
const statuses = criteria.status
|
||||||
|
? (Array.isArray(criteria.status) ? criteria.status : criteria.status.split(','))
|
||||||
|
: [];
|
||||||
Array.from(statusSelect.options).forEach(option => {
|
Array.from(statusSelect.options).forEach(option => {
|
||||||
option.selected = statuses.includes(option.value);
|
option.selected = statuses.includes(option.value);
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-8
@@ -198,9 +198,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
case 'open-settings-modal':
|
case 'open-settings-modal':
|
||||||
if (typeof openSettingsModal === 'function') openSettingsModal();
|
if (typeof openSettingsModal === 'function') openSettingsModal();
|
||||||
break;
|
break;
|
||||||
// Refresh
|
case 'close-settings':
|
||||||
|
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||||
|
break;
|
||||||
|
case 'save-settings':
|
||||||
|
if (typeof saveSettings === 'function') saveSettings();
|
||||||
|
break;
|
||||||
|
// Refresh — use lt.autoRefresh.now() so modal/focus guards are respected
|
||||||
case 'manual-refresh':
|
case 'manual-refresh':
|
||||||
window.location.reload();
|
if (window.lt && lt.autoRefresh) lt.autoRefresh.now();
|
||||||
|
else window.location.reload();
|
||||||
break;
|
break;
|
||||||
// Export
|
// Export
|
||||||
case 'toggle-export-menu':
|
case 'toggle-export-menu':
|
||||||
@@ -840,7 +847,7 @@ function showBulkDeleteModal() {
|
|||||||
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
|
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-body" style="text-align:center">
|
<div class="lt-modal-body lt-text-center">
|
||||||
<p class="lt-text-danger lt-text-sm">This action cannot be undone!</p>
|
<p class="lt-text-danger lt-text-sm">This action cannot be undone!</p>
|
||||||
<p class="lt-text-cyan">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
|
<p class="lt-text-cyan">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1332,21 +1339,25 @@ function showTicketPreview(event) {
|
|||||||
<div class="preview-footer">Created by ${lt.escHtml(createdBy)}</div>
|
<div class="preview-footer">Created by ${lt.escHtml(createdBy)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Position the preview
|
// Position the preview — element is position:fixed so coords are
|
||||||
|
// viewport-relative; getBoundingClientRect() already returns viewport coords,
|
||||||
|
// do NOT add scrollX/scrollY
|
||||||
const rect = link.getBoundingClientRect();
|
const rect = link.getBoundingClientRect();
|
||||||
const previewWidth = 320;
|
const previewWidth = 320;
|
||||||
const previewHeight = 200;
|
const previewHeight = 200;
|
||||||
|
|
||||||
let left = rect.left + window.scrollX;
|
let left = rect.left;
|
||||||
let top = rect.bottom + window.scrollY + 5;
|
let top = rect.bottom + 5;
|
||||||
|
|
||||||
// Adjust if going off-screen
|
// Adjust if going off-screen
|
||||||
if (left + previewWidth > window.innerWidth) {
|
if (left + previewWidth > window.innerWidth) {
|
||||||
left = window.innerWidth - previewWidth - 20;
|
left = window.innerWidth - previewWidth - 20;
|
||||||
}
|
}
|
||||||
if (top + previewHeight > window.innerHeight + window.scrollY) {
|
if (top + previewHeight > window.innerHeight) {
|
||||||
top = rect.top + window.scrollY - previewHeight - 5;
|
top = rect.top - previewHeight - 5;
|
||||||
}
|
}
|
||||||
|
if (left < 0) left = 4;
|
||||||
|
if (top < 0) top = 4;
|
||||||
|
|
||||||
currentPreview.style.left = left + 'px';
|
currentPreview.style.left = left + 'px';
|
||||||
currentPreview.style.top = top + 'px';
|
currentPreview.style.top = top + 'px';
|
||||||
@@ -1374,6 +1385,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide preview when a modal opens, user scrolls, or page is about to navigate
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('[data-modal-open], [data-action="open-advanced-search"], .lt-pagination a, .lt-pagination button')) {
|
||||||
|
hideTicketPreview();
|
||||||
|
if (currentPreview) currentPreview.classList.add('is-hidden');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('scroll', function() {
|
||||||
|
if (currentPreview && !currentPreview.classList.contains('is-hidden')) {
|
||||||
|
currentPreview.classList.add('is-hidden');
|
||||||
|
if (previewTimeout) { clearTimeout(previewTimeout); previewTimeout = null; }
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle export dropdown menu
|
* Toggle export dropdown menu
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||||
if (option && !option.disabled) {
|
if (option && !option.disabled) {
|
||||||
statusSelect.value = targetStatus;
|
statusSelect.value = targetStatus;
|
||||||
statusSelect.dispatchEvent(new Event('change'));
|
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1181,7 +1181,7 @@ function highlightMentions(text) {
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initMentionAutocomplete();
|
initMentionAutocomplete();
|
||||||
|
|
||||||
// Highlight existing mentions in comments
|
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
|
||||||
document.querySelectorAll('.comment-text').forEach(el => {
|
document.querySelectorAll('.comment-text').forEach(el => {
|
||||||
if (!el.hasAttribute('data-markdown')) {
|
if (!el.hasAttribute('data-markdown')) {
|
||||||
el.innerHTML = highlightMentions(el.innerHTML);
|
el.innerHTML = highlightMentions(el.innerHTML);
|
||||||
|
|||||||
+3
-3
@@ -26,7 +26,7 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
|||||||
const color = colors[type] || colors.warning;
|
const color = colors[type] || colors.warning;
|
||||||
const icon = icons[type] || icons.warning;
|
const icon = icons[type] || icons.warning;
|
||||||
const safeTitle = lt.escHtml(title);
|
const safeTitle = lt.escHtml(title);
|
||||||
const safeMessage = lt.escHtml(message);
|
const safeMessage = lt.escHtml(message).replace(/\n/g, '<br>');
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', `
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||||
@@ -35,8 +35,8 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
|||||||
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-body text-center">
|
<div class="lt-modal-body lt-text-center">
|
||||||
<p class="modal-message">${safeMessage}</p>
|
<p>${safeMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
|
|||||||
// Content Security Policy - restricts where resources can be loaded from
|
// Content Security Policy - restricts where resources can be loaded from
|
||||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
|
||||||
|
|
||||||
// Prevent clickjacking by disallowing framing
|
// Prevent clickjacking by disallowing framing
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: DENY");
|
||||||
|
|||||||
+14
-19
@@ -205,7 +205,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script nonce="<?= $nonce ?>">
|
||||||
// ── Dashboard charts (Chart.js, loaded from CDN) ─────────────────
|
// ── Dashboard charts (Chart.js, loaded from CDN) ─────────────────
|
||||||
(function() {
|
(function() {
|
||||||
function waitForChart(cb, tries) {
|
function waitForChart(cb, tries) {
|
||||||
@@ -431,7 +431,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<div class="lt-btn-group" style="flex-direction:column">
|
<div class="lt-btn-group lt-flex-col">
|
||||||
<button type="button" id="apply-filters-btn" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
|
<button type="button" id="apply-filters-btn" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
|
||||||
<button type="button" id="clear-filters-btn" class="lt-btn lt-btn-ghost lt-btn-sm">CLEAR ALL</button>
|
<button type="button" id="clear-filters-btn" class="lt-btn lt-btn-ghost lt-btn-sm">CLEAR ALL</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -586,7 +586,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||||
$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, 'page' => 1]);
|
||||||
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<th scope="col" class="<?= $sortClass ?>"
|
<th scope="col" class="<?= $sortClass ?>"
|
||||||
@@ -1086,7 +1086,7 @@ if (window.lt) {
|
|||||||
var c = JSON.parse(btn.dataset.criteria);
|
var c = JSON.parse(btn.dataset.criteria);
|
||||||
var params = new URLSearchParams();
|
var params = new URLSearchParams();
|
||||||
if (c.search) params.set('search', c.search);
|
if (c.search) params.set('search', c.search);
|
||||||
if (c.status && c.status.length) params.set('status', c.status.join(','));
|
if (c.status && c.status.length) params.set('status', Array.isArray(c.status) ? c.status.join(',') : c.status);
|
||||||
if (c.priority_min) params.set('priority_min', c.priority_min);
|
if (c.priority_min) params.set('priority_min', c.priority_min);
|
||||||
if (c.priority_max) params.set('priority_max', c.priority_max);
|
if (c.priority_max) params.set('priority_max', c.priority_max);
|
||||||
if (c.assigned_to) params.set('assigned_to', c.assigned_to);
|
if (c.assigned_to) params.set('assigned_to', c.assigned_to);
|
||||||
@@ -1124,27 +1124,19 @@ document.querySelectorAll('.lt-stat-card').forEach(function (card) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event delegation for click actions — only handles cases NOT covered by dashboard.js
|
// Event delegation — handles ONLY cases NOT covered by dashboard.js
|
||||||
// bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-*
|
// (bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-select-all,
|
||||||
// are all handled by dashboard.js to avoid double-firing (duplicate handlers = duplicate users in selects).
|
// toggle-row-checkbox, remove-filter, clear-all-filters, open/close/save-settings,
|
||||||
|
// open/toggle-export-menu, export-tickets, open-advanced-search are in dashboard.js)
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var target = e.target.closest('[data-action]');
|
var target = e.target.closest('[data-action]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
switch (target.getAttribute('data-action')) {
|
switch (target.getAttribute('data-action')) {
|
||||||
case 'open-settings': openSettingsModal(); break;
|
|
||||||
case 'close-settings': closeSettingsModal(); break;
|
|
||||||
case 'save-settings': saveSettings(); break;
|
|
||||||
case 'manual-refresh': if (lt.autoRefresh) lt.autoRefresh.now(); break;
|
|
||||||
case 'toggle-sidebar': if (typeof toggleSidebar==='function') toggleSidebar(); break;
|
case 'toggle-sidebar': if (typeof toggleSidebar==='function') toggleSidebar(); break;
|
||||||
case 'open-advanced-search': openAdvancedSearch(); break;
|
|
||||||
case 'close-advanced-search': closeAdvancedSearch(); break;
|
case 'close-advanced-search': closeAdvancedSearch(); break;
|
||||||
case 'reset-advanced-search': resetAdvancedSearch(); break;
|
case 'reset-advanced-search': resetAdvancedSearch(); break;
|
||||||
case 'toggle-export-menu': e.stopPropagation(); toggleExportMenu(e); break;
|
|
||||||
case 'export-tickets': e.preventDefault(); exportSelectedTickets(target.getAttribute('data-format')); break;
|
|
||||||
case 'save-filter': saveCurrentFilter(); break;
|
case 'save-filter': saveCurrentFilter(); break;
|
||||||
case 'delete-filter': deleteSavedFilter(); break;
|
case 'delete-filter': deleteSavedFilter(); break;
|
||||||
case 'remove-filter': removeFilter(target.getAttribute('data-filter-type'), target.getAttribute('data-filter-value')); break;
|
|
||||||
case 'clear-all-filters': window.location.href = '/'; break;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1158,9 +1150,12 @@ document.addEventListener('change', function (e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advanced search form submit
|
// Advanced search form submit — use wrapper so performAdvancedSearch is resolved at event time
|
||||||
|
// (advanced-search.js loads later via pageScripts in layout_footer.php)
|
||||||
var advForm = document.getElementById('advancedSearchForm');
|
var advForm = document.getElementById('advancedSearchForm');
|
||||||
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
if (advForm) advForm.addEventListener('submit', function(e) {
|
||||||
|
if (typeof performAdvancedSearch === 'function') performAdvancedSearch(e);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Flatpickr date pickers on advanced search date fields ────────
|
// ── Flatpickr date pickers on advanced search date fields ────────
|
||||||
(function initFlatpickr() {
|
(function initFlatpickr() {
|
||||||
@@ -1206,7 +1201,7 @@ if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="lt-drawer-right-overlay" id="ticketPreviewDrawerOverlay"></div>
|
<div class="lt-drawer-right-overlay" id="ticketPreviewDrawerOverlay"></div>
|
||||||
|
|
||||||
<script>
|
<script nonce="<?= $nonce ?>">
|
||||||
// ── Ticket Preview Drawer ──────────────────────────────────────────
|
// ── Ticket Preview Drawer ──────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
var drawer = document.getElementById('ticketPreviewDrawer');
|
var drawer = document.getElementById('ticketPreviewDrawer');
|
||||||
|
|||||||
@@ -1104,17 +1104,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click delegation for comment actions
|
// Click delegation — only handles actions NOT covered by ticket.js
|
||||||
|
// (edit-comment, delete-comment, remove-dependency, delete-attachment, select-mention,
|
||||||
|
// save/cancel-edit-comment, reply-comment, close-reply, submit-reply are in ticket.js)
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var target = e.target.closest('[data-action]');
|
var target = e.target.closest('[data-action]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
var action = target.getAttribute('data-action');
|
var action = target.getAttribute('data-action');
|
||||||
var commentId = target.getAttribute('data-comment-id');
|
if (action === 'dismiss-priority-banner') {
|
||||||
if (action === 'edit-comment' && commentId) {
|
|
||||||
if (typeof editComment === 'function') editComment(parseInt(commentId, 10));
|
|
||||||
} else if (action === 'delete-comment' && commentId) {
|
|
||||||
if (typeof deleteComment === 'function') deleteComment(parseInt(commentId, 10));
|
|
||||||
} else if (action === 'dismiss-priority-banner') {
|
|
||||||
var banner = target.closest('[data-alert-id]');
|
var banner = target.closest('[data-alert-id]');
|
||||||
if (banner) {
|
if (banner) {
|
||||||
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
|
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user