8 Commits

Author SHA1 Message Date
jared 1989bcb8c8 Migrate status and priority display to lt-status/lt-priority design system classes
DashboardView.php:
- Table status column: replace status-{slug} with lt-status lt-status-{slug} for consistent [● Status] bracket decoration from base.css
- Table priority column: replace raw number with lt-priority lt-p{N} empty span for [▲▲ P1 CRITICAL] style badges

dashboard.js:
- Kanban card priority badge: replace card-priority p{N} with lt-priority lt-p{N} to use the design system badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:25:49 -04:00
jared 0a2214bfaf Improve web_template compliance: lt.bytes.format, lt.tableNav, lt.statsFilter
- ticket.js: replace custom formatFileSize() with lt.bytes.format() from web_template base.js; remove the now-redundant local function
- DashboardView.php: add id="tickets-table" and wire lt.tableNav.init() for j/k/Enter keyboard row navigation
- DashboardView.php: add lt-stat-card class + data-filter-key/data-filter-val to open/critical/closed stat cards; wire lt.statsFilter.init() + window.lt_onStatFilter so clicking a stat card filters the ticket list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:07:49 -04:00
jared e7d01ef576 Return 404 (not 403) for inaccessible tickets in TicketController
Returning 403 Forbidden leaks the existence of tickets to users who
should not know about them. Use 404 Not Found consistently across all
access-controlled endpoints to prevent enumeration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:28 -04:00
jared a403e49537 Use canUserAccessTicket() in clone_ticket.php; fix README bootstrap entry
- clone_ticket.php: replace custom visibility check with centralized canUserAccessTicket(); return 404 (not 403) for inaccessible tickets
- README.md: remove bootstrap.php from the API endpoints table (it's a shared include, not a public endpoint); correct its project structure description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:03 -04:00
jared 06b7a8f59b Consolidate showConfirmModal into utils.js, remove duplicate from dashboard.js
utils.js is loaded on all pages (dashboard, ticket, admin views) before dashboard.js.
Moving the canonical definition there and removing the guard + the copy in dashboard.js
eliminates the redundant redefinition on every page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:46 -04:00
jared 9f1a375e5a Apply visibility filtering to dashboard statistics
StatsModel.getAllStats() now accepts a user array and applies the same
getVisibilityFilter() logic used by ticket listings. Admins continue to
share a single cached result; non-admin users get per-user cache entries
so confidential ticket counts are not leaked in dashboard stats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:01 -04:00
jared 84cc023bc4 Enforce ticket visibility on attachment and update endpoints
- delete_attachment.php: check canUserAccessTicket() before allowing deletion; return 404 (not 403) for inaccessible tickets to prevent existence leakage
- upload_attachment.php: verify ticket access on both GET (list) and POST (upload) before processing
- update_ticket.php: pass currentUser to controller; add canUserAccessTicket() check before permission check; return 404 for inaccessible tickets instead of leaking existence via 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:42:47 -04:00
jared 164c2d231a Fix visibility enforcement and register missing API routes
Security fixes:
- add_comment.php: verify canUserAccessTicket() before allowing comment creation
- assign_ticket.php: use canUserAccessTicket() to prevent info leakage via 403 vs 404
- check_duplicates.php: apply getVisibilityFilter() so confidential ticket titles are not exposed in duplicate search results
- ticket_dependencies.php: verify ticket access on GET before returning dependency data

Route registration:
- Register 7 previously missing API endpoints in index.php: custom_fields, saved_filters, audit_log, user_preferences, download_attachment, clone_ticket, health

Frontend:
- ticket.js: fill empty catch block and empty else block in addComment() with proper error toasts

Documentation:
- README.md: document all API endpoints and update project structure listing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:39:02 -04:00
17 changed files with 250 additions and 153 deletions
+18 -3
View File
@@ -204,6 +204,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/api/update_ticket.php` | POST | Update ticket with workflow validation | | `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user | | `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket | | `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template | | `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments | | `/api/get_users.php` | GET | Get user list for assignments |
| `/api/bulk_operation.php` | POST | Perform bulk operations | | `/api/bulk_operation.php` | POST | Perform bulk operations |
@@ -220,6 +221,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) | | `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
| `/api/manage_templates.php` | CRUD | Templates (admin) | | `/api/manage_templates.php` | CRUD | Templates (admin) |
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) | | `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
| `/api/user_preferences.php` | GET/POST | User preferences |
| `/api/audit_log.php` | GET | Audit log entries (admin) |
| `/api/health.php` | GET | Health check |
## Project Structure ## Project Structure
@@ -228,8 +234,12 @@ tinker_tickets/
├── api/ ├── api/
│ ├── add_comment.php # POST: Add comment │ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user │ ├── assign_ticket.php # POST: Assign ticket to user
│ ├── audit_log.php # GET: Audit log entries (admin)
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
│ ├── bulk_operation.php # POST: Bulk operations (admin only) │ ├── bulk_operation.php # POST: Bulk operations (admin only)
│ ├── check_duplicates.php # GET: Check for duplicate tickets │ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── clone_ticket.php # POST: Clone an existing ticket
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
│ ├── delete_attachment.php # POST/DELETE: Delete attachment │ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin) │ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download with visibility check │ ├── download_attachment.php # GET: Download with visibility check
@@ -237,23 +247,26 @@ tinker_tickets/
│ ├── generate_api_key.php # POST: Generate API key (admin) │ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template │ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list │ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin) │ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
│ ├── manage_templates.php # CRUD: Templates (admin) │ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin) │ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── revoke_api_key.php # POST: Revoke API key (admin) │ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── saved_filters.php # CRUD: Saved filter combinations
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies │ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin) │ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation) │ ├── update_ticket.php # POST: Update ticket (workflow validation)
── upload_attachment.php # GET/POST: List or upload attachments ── upload_attachment.php # GET/POST: List or upload attachments
│ └── user_preferences.php # GET/POST: User preferences
├── assets/ ├── assets/
│ ├── css/ │ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template) │ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling │ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling │ │ └── ticket.css # Ticket view styling
│ ├── js/ │ ├── js/
│ │ ├── advanced-search.js # Advanced search modal │ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit) │ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template) │ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar │ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys) │ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe) │ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
@@ -292,6 +305,8 @@ tinker_tickets/
│ ├── UserPreferencesModel.php # User preferences │ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows │ └── WorkflowModel.php # Status transition workflows
├── scripts/ ├── scripts/
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads │ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
│ └── create_dependencies_table.php # Create ticket_dependencies table │ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage ├── uploads/ # File attachment storage
+19
View File
@@ -28,6 +28,7 @@ try {
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Check authentication via session // Check authentication via session
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
@@ -71,6 +72,24 @@ try {
exit; exit;
} }
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models // Initialize models
$commentModel = new CommentModel($conn); $commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn); $auditLog = new AuditLogModel($conn);
+2 -2
View File
@@ -25,9 +25,9 @@ $ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn); $auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn); $userModel = new UserModel($conn);
// Verify ticket exists // Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId); $ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) { if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']); echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit; exit;
+11 -1
View File
@@ -7,6 +7,7 @@
require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Only accept GET requests // Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
@@ -30,6 +31,10 @@ $searchTerm = '%' . $title . '%';
// Get SOUNDEX of title // Get SOUNDEX of title
$soundexTitle = soundex($title); $soundexTitle = soundex($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// First, search for exact substring matches (case-insensitive) // First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at $sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets FROM tickets
@@ -38,11 +43,16 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
OR SOUNDEX(title) = ? OR SOUNDEX(title) = ?
) )
AND status != 'Closed' AND status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 10"; LIMIT 10";
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);
$stmt->bind_param("ss", $searchTerm, $soundexTitle); if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
+4 -6
View File
@@ -74,14 +74,12 @@ try {
exit; exit;
} }
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned // Verify the user can access this ticket using centralized visibility logic
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') { if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) { http_response_code(404);
http_response_code(403); echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit; exit;
} }
}
// Prepare cloned ticket data // Prepare cloned ticket data
$clonedTicketData = [ $clonedTicketData = [
+9 -1
View File
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -66,7 +67,14 @@ try {
ResponseHelper::notFound('Attachment not found'); ResponseHelper::notFound('Attachment not found');
} }
// Check permission // Verify user can access the parent ticket
$ticketModel = new TicketModel(Database::getConnection());
$ticket = $ticketModel->getTicketById((int)$attachment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Attachment not found');
}
// Check permission (must be uploader or admin)
$isAdmin = $_SESSION['user']['is_admin'] ?? false; $isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) { if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
ResponseHelper::forbidden('You do not have permission to delete this attachment'); ResponseHelper::forbidden('You do not have permission to delete this attachment');
+9
View File
@@ -67,6 +67,7 @@ require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/DependencyModel.php'; require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -77,6 +78,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
} }
$userId = $_SESSION['user']['user_id']; $userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
// CSRF Protection for POST/DELETE // CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') { if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
@@ -99,6 +101,7 @@ if ($tableCheck->num_rows === 0) {
try { try {
$dependencyModel = new DependencyModel($conn); $dependencyModel = new DependencyModel($conn);
$auditLog = new AuditLogModel($conn); $auditLog = new AuditLogModel($conn);
$ticketModel = new TicketModel($conn);
} catch (Exception $e) { } catch (Exception $e) {
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage()); error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
ResponseHelper::serverError('Failed to initialize required components'); ResponseHelper::serverError('Failed to initialize required components');
@@ -116,6 +119,12 @@ switch ($method) {
ResponseHelper::error('Ticket ID required'); ResponseHelper::error('Ticket ID required');
} }
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try { try {
$dependencies = $dependencyModel->getDependencies($ticketId); $dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId); $dependents = $dependencyModel->getDependentTickets($ticketId);
+17 -2
View File
@@ -59,14 +59,16 @@ try {
private $workflowModel; private $workflowModel;
private $userId; private $userId;
private $isAdmin; private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false) { public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn); $this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn); $this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn); $this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId; $this->userId = $userId;
$this->isAdmin = $isAdmin; $this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
} }
public function update($id, $data) { public function update($id, $data) {
@@ -79,6 +81,15 @@ try {
]; ];
} }
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Authorization: admins can edit any ticket; others only their own or assigned // Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin if (!$this->isAdmin
&& $currentTicket['created_by'] != $this->userId && $currentTicket['created_by'] != $this->userId
@@ -206,7 +217,7 @@ try {
$ticketId = (int)$data['ticket_id']; $ticketId = (int)$data['ticket_id'];
// Initialize controller // Initialize controller
$controller = new ApiTicketController($conn, $userId, $isAdmin); $controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
// Update ticket // Update ticket
$result = $controller->update($ticketId, $data); $result = $controller->update($ticketId, $data);
@@ -215,6 +226,10 @@ try {
ob_end_clean(); ob_end_clean();
// Return response // Return response
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
}
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($result); echo json_encode($result);
+17 -1
View File
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -46,7 +47,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
} }
try { try {
$attachmentModel = new AttachmentModel(Database::getConnection()); $conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
$attachmentModel = new AttachmentModel($conn);
$attachments = $attachmentModel->getAttachments($ticketId); $attachments = $attachmentModel->getAttachments($ticketId);
// Add formatted file size and icon to each attachment // Add formatted file size and icon to each attachment
@@ -83,6 +91,14 @@ if (!preg_match('/^\d{9}$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format'); ResponseHelper::error('Invalid ticket ID format');
} }
// Verify user can access the ticket before accepting upload
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
// Check if file was uploaded // Check if file was uploaded
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) { if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
ResponseHelper::error('No file uploaded'); ResponseHelper::error('No file uploaded');
+1 -66
View File
@@ -1079,71 +1079,6 @@ function performBulkDelete() {
// TERMINAL-STYLE MODAL UTILITIES // TERMINAL-STYLE MODAL UTILITIES
// ============================================ // ============================================
/**
* Show a terminal-style confirmation modal
* @param {string} title - Modal title
* @param {string} message - Message body
* @param {string} type - 'warning', 'error', 'info' (affects color)
* @param {function} onConfirm - Callback when user confirms
* @param {function} onCancel - Optional callback when user cancels
*/
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
// Color scheme based on type
const colors = {
warning: 'var(--terminal-amber)',
error: 'var(--status-closed)',
info: 'var(--terminal-cyan)'
};
const color = colors[type] || colors.warning;
// Icon based on type
const icons = {
warning: '[ ! ]',
error: '[ X ]',
info: '[ i ]',
};
const icon = icons[type] || icons.warning;
// Escape user-provided content to prevent XSS
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message);
const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color: ${color};">
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body text-center">
<p class="modal-message">${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (cb) => {
lt.modal.close(modalId);
setTimeout(() => modal.remove(), 300);
if (cb) cb();
};
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
}
/** /**
* Show a terminal-style input modal * Show a terminal-style input modal
* @param {string} title - Modal title * @param {string} title - Modal title
@@ -1413,7 +1348,7 @@ function populateKanbanCards() {
card.innerHTML = ` card.innerHTML = `
<div class="card-header"> <div class="card-header">
<span class="card-id">#${lt.escHtml(ticketId)}</span> <span class="card-id">#${lt.escHtml(ticketId)}</span>
<span class="card-priority p${priority}">P${priority}</span> <span class="lt-priority lt-p${priority}"></span>
</div> </div>
<div class="card-title">${lt.escHtml(title)}</div> <div class="card-title">${lt.escHtml(title)}</div>
<div class="card-footer"> <div class="card-footer">
+3 -12
View File
@@ -188,9 +188,11 @@ function addComment() {
commentsList.insertBefore(commentDiv, commentsList.firstChild); commentsList.insertBefore(commentDiv, commentsList.firstChild);
} else { } else {
lt.toast.error(data.error || 'Failed to add comment');
} }
}) })
.catch(error => { .catch(error => {
lt.toast.error('Error adding comment: ' + error.message);
}); });
} }
@@ -825,7 +827,7 @@ function renderAttachments(attachments) {
</a> </a>
</div> </div>
<div class="attachment-meta"> <div class="attachment-meta">
${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))}${lt.escHtml(uploaderName)}${uploadDate} ${lt.escHtml(att.file_size_formatted || lt.bytes.format(att.file_size))}${lt.escHtml(uploaderName)}${uploadDate}
</div> </div>
</div> </div>
<div class="attachment-actions"> <div class="attachment-actions">
@@ -839,17 +841,6 @@ function renderAttachments(attachments) {
container.innerHTML = html; container.innerHTML = html;
} }
function formatFileSize(bytes) {
if (bytes >= 1073741824) {
return (bytes / 1073741824).toFixed(2) + ' GB';
} else if (bytes >= 1048576) {
return (bytes / 1048576).toFixed(2) + ' MB';
} else if (bytes >= 1024) {
return (bytes / 1024).toFixed(2) + ' KB';
} else {
return bytes + ' bytes';
}
}
function deleteAttachment(attachmentId) { function deleteAttachment(attachmentId) {
showConfirmModal( showConfirmModal(
+1 -4
View File
@@ -13,15 +13,13 @@ function getTicketIdFromUrl() {
/** /**
* Show a terminal-style confirmation modal using the lt.modal system. * Show a terminal-style confirmation modal using the lt.modal system.
* Falls back gracefully if dashboard.js has already defined this function.
* @param {string} title - Modal title * @param {string} title - Modal title
* @param {string} message - Confirmation message * @param {string} message - Confirmation message
* @param {string} type - 'warning' | 'error' | 'info' * @param {string} type - 'warning' | 'error' | 'info'
* @param {Function} onConfirm - Called when user confirms * @param {Function} onConfirm - Called when user confirms
* @param {Function|null} onCancel - Called when user cancels * @param {Function|null} onCancel - Called when user cancels
*/ */
if (typeof showConfirmModal === 'undefined') { function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now(); const modalId = 'confirmModal' + Date.now();
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' }; const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' }; const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
@@ -54,5 +52,4 @@ if (typeof showConfirmModal === 'undefined') {
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm)); document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel)); document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel)); modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
};
} }
+1 -1
View File
@@ -155,7 +155,7 @@ class DashboardController {
$totalPages = $result['pages']; $totalPages = $result['pages'];
// Load dashboard statistics // Load dashboard statistics
$stats = $this->statsModel->getAllStats(); $stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
// Load the dashboard view // Load the dashboard view
include 'views/DashboardView.php'; include 'views/DashboardView.php';
+3 -3
View File
@@ -42,10 +42,10 @@ class TicketController {
return; return;
} }
// Check visibility access // Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) { if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header("HTTP/1.0 403 Forbidden"); header("HTTP/1.0 404 Not Found");
echo "Access denied: You do not have permission to view this ticket"; echo "Ticket not found";
return; return;
} }
+28
View File
@@ -146,6 +146,34 @@ switch (true) {
require_once 'api/check_duplicates.php'; require_once 'api/check_duplicates.php';
break; break;
case $requestPath == '/api/custom_fields.php':
require_once 'api/custom_fields.php';
break;
case $requestPath == '/api/saved_filters.php':
require_once 'api/saved_filters.php';
break;
case $requestPath == '/api/audit_log.php':
require_once 'api/audit_log.php';
break;
case $requestPath == '/api/user_preferences.php':
require_once 'api/user_preferences.php';
break;
case $requestPath == '/api/download_attachment.php':
require_once 'api/download_attachment.php';
break;
case $requestPath == '/api/clone_ticket.php':
require_once 'api/clone_ticket.php';
break;
case $requestPath == '/api/health.php':
require_once 'api/health.php';
break;
// Admin Routes - require admin privileges // Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets': case $requestPath == '/admin/recurring-tickets':
if (!$currentUser || !$currentUser['is_admin']) { if (!$currentUser || !$currentUser['is_admin']) {
+45 -13
View File
@@ -7,6 +7,7 @@
*/ */
require_once dirname(__DIR__) . '/helpers/CacheHelper.php'; require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
class StatsModel { class StatsModel {
private mysqli $conn; private mysqli $conn;
@@ -173,15 +174,19 @@ class StatsModel {
} }
/** /**
* Get all stats as a single array * Get all stats as a single array, respecting ticket visibility for the given user.
* *
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds. * Admins use a shared cache; non-admins use a per-user cache key so confidential
* tickets are not counted in stats for users who cannot access them.
* *
* @param array $user Current user array (must include user_id, is_admin, groups)
* @param bool $forceRefresh Force a cache refresh * @param bool $forceRefresh Force a cache refresh
* @return array All dashboard statistics * @return array All dashboard statistics
*/ */
public function getAllStats(bool $forceRefresh = false): array { public function getAllStats(array $user = [], bool $forceRefresh = false): array {
$cacheKey = 'dashboard_all'; $isAdmin = !empty($user['is_admin']);
// Admins share one cache entry; non-admins get a per-user cache entry
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
if ($forceRefresh) { if ($forceRefresh) {
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey); CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
@@ -190,21 +195,28 @@ class StatsModel {
return CacheHelper::remember( return CacheHelper::remember(
self::CACHE_PREFIX, self::CACHE_PREFIX,
$cacheKey, $cacheKey,
function() { function() use ($user) {
return $this->fetchAllStats(); return $this->fetchAllStats($user);
}, },
self::STATS_CACHE_TTL self::STATS_CACHE_TTL
); );
} }
/** /**
* Fetch all stats from database (uncached) * Fetch all stats from database (uncached), filtered by the given user's visibility.
* *
* Uses consolidated queries to reduce database round-trips from 12 to 4. * Uses consolidated queries to reduce database round-trips.
* *
* @param array $user Current user array
* @return array All dashboard statistics * @return array All dashboard statistics
*/ */
private function fetchAllStats(): array { private function fetchAllStats(array $user = []): array {
$ticketModel = new TicketModel($this->conn);
$visFilter = $ticketModel->getVisibilityFilter($user);
$visSQL = $visFilter['sql'];
$visParams = $visFilter['params'];
$visTypes = $visFilter['types'];
// Query 1: Get all simple counts in one query using conditional aggregation // Query 1: Get all simple counts in one query using conditional aggregation
$countsSql = "SELECT $countsSql = "SELECT
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets, SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
@@ -216,23 +228,43 @@ class StatsModel {
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical, SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
FROM tickets"; FROM tickets WHERE ($visSQL)";
if (!empty($visParams)) {
$stmt = $this->conn->prepare($countsSql);
$stmt->bind_param($visTypes, ...$visParams);
$stmt->execute();
$countsResult = $stmt->get_result();
$stmt->close();
} else {
$countsResult = $this->conn->query($countsSql); $countsResult = $this->conn->query($countsSql);
}
$counts = $countsResult->fetch_assoc(); $counts = $countsResult->fetch_assoc();
// Query 2: Get priority, status, and category breakdowns in one query // Query 2: Get priority, status, and category breakdowns in one query
$breakdownSql = "SELECT $breakdownSql = "SELECT
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count 'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY priority FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets GROUP BY status FROM tickets WHERE ($visSQL) GROUP BY status
UNION ALL UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY category"; FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
if (!empty($visParams)) {
// Need to bind params 3 times (once per UNION branch)
$tripleParams = array_merge($visParams, $visParams, $visParams);
$tripleTypes = $visTypes . $visTypes . $visTypes;
$stmt = $this->conn->prepare($breakdownSql);
$stmt->bind_param($tripleTypes, ...$tripleParams);
$stmt->execute();
$breakdownResult = $stmt->get_result();
$stmt->close();
} else {
$breakdownResult = $this->conn->query($breakdownSql); $breakdownResult = $this->conn->query($breakdownSql);
}
$byPriority = []; $byPriority = [];
$byStatus = []; $byStatus = [];
$byCategory = []; $byCategory = [];
+31 -7
View File
@@ -183,14 +183,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php if (isset($stats)): ?> <?php if (isset($stats)): ?>
<div class="stats-widgets"> <div class="stats-widgets">
<div class="stats-row"> <div class="stats-row">
<div class="stat-card stat-open"> <div class="stat-card lt-stat-card stat-open" data-filter-key="status" data-filter-val="Open,Pending,In Progress" title="Click to filter by open tickets">
<div class="stat-icon">[ # ]</div> <div class="stat-icon">[ # ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div> <div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
<div class="stat-label">Open Tickets</div> <div class="stat-label">Open Tickets</div>
</div> </div>
</div> </div>
<div class="stat-card stat-critical"> <div class="stat-card lt-stat-card stat-critical" data-filter-key="priority" data-filter-val="1" title="Click to filter critical tickets">
<div class="stat-icon">[ ! ]</div> <div class="stat-icon">[ ! ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['critical']; ?></div> <div class="stat-value"><?php echo $stats['critical']; ?></div>
@@ -211,7 +211,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="stat-label">Created Today</div> <div class="stat-label">Created Today</div>
</div> </div>
</div> </div>
<div class="stat-card stat-resolved"> <div class="stat-card lt-stat-card stat-resolved" data-filter-key="status" data-filter-val="Closed" title="Click to filter closed tickets">
<div class="stat-icon">[ OK ]</div> <div class="stat-icon">[ OK ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['closed_today']; ?></div> <div class="stat-value"><?php echo $stats['closed_today']; ?></div>
@@ -385,7 +385,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- Table --> <!-- Table -->
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table id="tickets-table">
<thead> <thead>
<tr> <tr>
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?> <?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
@@ -438,12 +438,13 @@ $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>"; $pNum = (int)$row['priority'];
echo "<td><span class='lt-priority lt-p{$pNum}'></span></td>";
echo "<td>" . htmlspecialchars($row['title']) . "</td>"; echo "<td>" . htmlspecialchars($row['title']) . "</td>";
echo "<td>" . htmlspecialchars($row['category']) . "</td>"; echo "<td>" . htmlspecialchars($row['category']) . "</td>";
echo "<td>" . htmlspecialchars($row['type']) . "</td>"; echo "<td>" . htmlspecialchars($row['type']) . "</td>";
$statusSlug = htmlspecialchars(str_replace(' ', '-', $row['status']), ENT_QUOTES); $statusSlug = strtolower(str_replace(' ', '-', $row['status']));
echo "<td><span class='status-" . $statusSlug . "'>" . htmlspecialchars($row['status']) . "</span></td>"; echo "<td><span class='lt-status lt-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>";
@@ -838,6 +839,29 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help) // Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
if (window.lt) lt.keys.initDefaults(); if (window.lt) lt.keys.initDefaults();
// Enable j/k/Enter keyboard row navigation on the ticket table
if (window.lt) lt.tableNav.init('tickets-table');
// Enable stat card click-to-filter using lt.statsFilter
if (window.lt) lt.statsFilter.init();
window.lt_onStatFilter = function(key, val) {
const url = new URL(window.location.href);
if (key === null) {
// Toggle off — clear filter params
url.searchParams.delete('status');
url.searchParams.delete('priority_min');
url.searchParams.delete('priority_max');
} else if (key === 'status') {
url.searchParams.set('status', val);
url.searchParams.delete('priority_min');
url.searchParams.delete('priority_max');
} else if (key === 'priority') {
url.searchParams.set('priority_min', val);
url.searchParams.set('priority_max', val);
url.searchParams.delete('status');
}
url.searchParams.delete('page');
window.location.href = url.toString();
};
// Event delegation for all data-action handlers // Event delegation for all data-action handlers
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]'); const target = event.target.closest('[data-action]');