From e86a5de3fd7dc71c6fa0f26b9e5aaf1fed5b0e88 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 23 Jan 2026 10:01:50 -0500 Subject: [PATCH] feat: Add 9 new features for enhanced UX and security Quick Wins: - Feature 1: Ticket linking in comments (#123456789 auto-links) - Feature 6: Checkbox click area fix (click anywhere in cell) - Feature 7: User groups display in settings modal UI Enhancements: - Feature 4: Collapsible sidebar with localStorage persistence - Feature 5: Inline ticket preview popup on hover (300ms delay) - Feature 2: Mobile responsive improvements (44px touch targets, iOS zoom fix) Major Features: - Feature 3: Kanban card view with status columns (toggle with localStorage) - Feature 9: API key generation admin panel (/admin/api-keys) - Feature 8: Ticket visibility levels (public/internal/confidential) New files: - views/admin/ApiKeysView.php - api/generate_api_key.php - api/revoke_api_key.php - migrations/008_ticket_visibility.sql Co-Authored-By: Claude Opus 4.5 --- api/download_attachment.php | 37 +- api/generate_api_key.php | 127 ++++++ api/get_template.php | 7 +- api/revoke_api_key.php | 120 ++++++ api/ticket_dependencies.php | 28 +- api/update_ticket.php | 10 + assets/css/dashboard.css | 602 +++++++++++++++++++++++++++ assets/css/ticket.css | 113 +++++ assets/js/dashboard.js | 267 ++++++++++++ assets/js/markdown.js | 3 + controllers/TicketController.php | 22 +- index.php | 12 + migrations/008_ticket_visibility.sql | 15 + models/TicketModel.php | 142 ++++++- models/UserModel.php | 25 ++ views/CreateTicketView.php | 58 ++- views/DashboardView.php | 65 ++- views/TicketView.php | 74 +++- views/admin/ApiKeysView.php | 245 +++++++++++ 19 files changed, 1933 insertions(+), 39 deletions(-) create mode 100644 api/generate_api_key.php create mode 100644 api/revoke_api_key.php create mode 100644 migrations/008_ticket_visibility.sql create mode 100644 views/admin/ApiKeysView.php diff --git a/api/download_attachment.php b/api/download_attachment.php index 8fcb4a0..4ae63c6 100644 --- a/api/download_attachment.php +++ b/api/download_attachment.php @@ -8,6 +8,7 @@ session_start(); require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php'; +require_once dirname(__DIR__) . '/models/TicketModel.php'; // Check authentication if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { @@ -40,18 +41,52 @@ try { exit; } + // Verify the associated ticket exists (access control) + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + if (!$conn->connect_error) { + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($attachment['ticket_id']); + $conn->close(); + + if (!$ticket) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Associated ticket not found']); + exit; + } + } + // Build file path $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; + // Security: Verify the resolved path is within the uploads directory (prevent path traversal) + $realUploadDir = realpath($uploadDir); + $realFilePath = realpath($filePath); + + if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; + } + // Check if file exists - if (!file_exists($filePath)) { + if (!file_exists($realFilePath)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'File not found on server']); exit; } + // Use the validated real path + $filePath = $realFilePath; + // Determine if we should display inline or force download $inline = isset($_GET['inline']) && $_GET['inline'] === '1'; $inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']; diff --git a/api/generate_api_key.php b/api/generate_api_key.php new file mode 100644 index 0000000..91c7e8a --- /dev/null +++ b/api/generate_api_key.php @@ -0,0 +1,127 @@ + 100) { + throw new Exception("Key name must be 100 characters or less"); + } + + // Validate expires_in_days if provided + if ($expiresInDays !== null && $expiresInDays !== '') { + $expiresInDays = (int)$expiresInDays; + if ($expiresInDays < 1 || $expiresInDays > 3650) { + throw new Exception("Expiration must be between 1 and 3650 days"); + } + } else { + $expiresInDays = null; + } + + // Create database connection + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + // Generate API key + $apiKeyModel = new ApiKeyModel($conn); + $result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays); + + if (!$result['success']) { + throw new Exception($result['error'] ?? "Failed to generate API key"); + } + + // Log the action + $auditLog = new AuditLogModel($conn); + $auditLog->log( + $_SESSION['user']['user_id'], + 'create', + 'api_key', + $result['key_id'], + ['key_name' => $keyName, 'expires_in_days' => $expiresInDays] + ); + + $conn->close(); + + // Clear output buffer + ob_end_clean(); + + // Return success with the plaintext key (shown only once) + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'api_key' => $result['api_key'], + 'key_prefix' => $result['key_prefix'], + 'key_id' => $result['key_id'], + 'expires_at' => $result['expires_at'] + ]); + +} catch (Exception $e) { + ob_end_clean(); + header('Content-Type: application/json'); + http_response_code(isset($conn) ? 400 : 500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); +} diff --git a/api/get_template.php b/api/get_template.php index 1457cb5..c7b3b3b 100644 --- a/api/get_template.php +++ b/api/get_template.php @@ -14,11 +14,14 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { // Get template ID from query parameter $templateId = $_GET['template_id'] ?? null; -if (!$templateId) { - echo json_encode(['success' => false, 'error' => 'Template ID required']); +if (!$templateId || !is_numeric($templateId)) { + echo json_encode(['success' => false, 'error' => 'Valid template ID required']); exit; } +// Cast to integer for safety +$templateId = (int)$templateId; + // Create database connection $conn = new mysqli( $GLOBALS['config']['DB_HOST'], diff --git a/api/revoke_api_key.php b/api/revoke_api_key.php new file mode 100644 index 0000000..c5d910f --- /dev/null +++ b/api/revoke_api_key.php @@ -0,0 +1,120 @@ +connect_error) { + throw new Exception("Database connection failed"); + } + + // Get key info for audit log + $apiKeyModel = new ApiKeyModel($conn); + $keyInfo = $apiKeyModel->getKeyById($keyId); + + if (!$keyInfo) { + throw new Exception("API key not found"); + } + + if (!$keyInfo['is_active']) { + throw new Exception("API key is already revoked"); + } + + // Revoke the key + $success = $apiKeyModel->revokeKey($keyId); + + if (!$success) { + throw new Exception("Failed to revoke API key"); + } + + // Log the action + $auditLog = new AuditLogModel($conn); + $auditLog->log( + $_SESSION['user']['user_id'], + 'revoke', + 'api_key', + $keyId, + ['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']] + ); + + $conn->close(); + + // Clear output buffer + ob_end_clean(); + + // Return success + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'message' => 'API key revoked successfully' + ]); + +} catch (Exception $e) { + ob_end_clean(); + header('Content-Type: application/json'); + http_response_code(isset($conn) ? 400 : 500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); +} diff --git a/api/ticket_dependencies.php b/api/ticket_dependencies.php index 0bd0c39..6434e49 100644 --- a/api/ticket_dependencies.php +++ b/api/ticket_dependencies.php @@ -11,14 +11,14 @@ header('Content-Type: application/json'); register_shutdown_function(function() { $error = error_get_last(); if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) { + // Log detailed error server-side + error_log('Fatal error in ticket_dependencies.php: ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']); ob_end_clean(); http_response_code(500); header('Content-Type: application/json'); echo json_encode([ 'success' => false, - 'error' => 'Fatal: ' . $error['message'], - 'file' => basename($error['file']), - 'line' => $error['line'] + 'error' => 'A server error occurred' ]); } }); @@ -28,28 +28,28 @@ error_reporting(E_ALL); // Custom error handler set_error_handler(function($errno, $errstr, $errfile, $errline) { + // Log detailed error server-side + error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline"); ob_end_clean(); http_response_code(500); header('Content-Type: application/json'); echo json_encode([ 'success' => false, - 'error' => "PHP Error: $errstr", - 'file' => basename($errfile), - 'line' => $errline + 'error' => 'A server error occurred' ]); exit; }); // Custom exception handler set_exception_handler(function($e) { + // Log detailed error server-side + error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); ob_end_clean(); http_response_code(500); header('Content-Type: application/json'); echo json_encode([ 'success' => false, - 'error' => 'Exception: ' . $e->getMessage(), - 'file' => basename($e->getFile()), - 'line' => $e->getLine() + 'error' => 'A server error occurred' ]); exit; }); @@ -108,7 +108,8 @@ try { $dependencyModel = new DependencyModel($conn); $auditLog = new AuditLogModel($conn); } catch (Exception $e) { - ResponseHelper::serverError('Failed to initialize models: ' . $e->getMessage()); + error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage()); + ResponseHelper::serverError('Failed to initialize required components'); } $method = $_SERVER['REQUEST_METHOD']; @@ -127,7 +128,8 @@ switch ($method) { $dependencies = $dependencyModel->getDependencies($ticketId); $dependents = $dependencyModel->getDependentTickets($ticketId); } catch (Exception $e) { - ResponseHelper::serverError('Query error: ' . $e->getMessage()); + error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage()); + ResponseHelper::serverError('Failed to retrieve dependencies'); } ResponseHelper::success([ @@ -206,7 +208,9 @@ switch ($method) { ResponseHelper::error('Method not allowed', 405); } } catch (Exception $e) { - ResponseHelper::serverError('An error occurred: ' . $e->getMessage()); + // Log detailed error server-side + error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + ResponseHelper::serverError('An error occurred while processing the dependency request'); } $conn->close(); diff --git a/api/update_ticket.php b/api/update_ticket.php index ee76630..c0c4ad6 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -144,6 +144,16 @@ try { // Update ticket with user tracking $result = $this->ticketModel->updateTicket($updateData, $this->userId); + // Handle visibility update if provided + if ($result && isset($data['visibility'])) { + $visibilityGroups = $data['visibility_groups'] ?? null; + // Convert array to comma-separated string if needed + if (is_array($visibilityGroups)) { + $visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); + } + $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); + } + if ($result) { // Log ticket update to audit log if ($this->userId) { diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 9cb5c63..7d3dbdb 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -1691,6 +1691,81 @@ input[type="checkbox"]:checked { box-shadow: var(--glow-amber); } +/* Collapsible Sidebar */ +.sidebar-toggle { + position: absolute; + right: -16px; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 64px; + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); + border-left: none; + color: var(--terminal-green); + cursor: pointer; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + padding: 0; +} + +.sidebar-toggle::before, +.sidebar-toggle::after { + content: ''; +} + +.sidebar-toggle:hover { + background: rgba(0, 255, 65, 0.15); + color: var(--terminal-amber); + border-color: var(--terminal-amber); +} + +.toggle-arrow { + transition: transform 0.3s ease; +} + +.dashboard-sidebar { + position: relative; + transition: width 0.3s ease, margin 0.3s ease; +} + +.sidebar-content { + overflow: hidden; + transition: opacity 0.3s ease; +} + +.dashboard-sidebar.collapsed { + width: 0; + margin-right: 16px; +} + +.dashboard-sidebar.collapsed .sidebar-content { + opacity: 0; + pointer-events: none; +} + +.dashboard-sidebar.collapsed .sidebar-toggle { + right: -48px; +} + +.dashboard-sidebar.collapsed .toggle-arrow { + transform: rotate(180deg); +} + +.dashboard-layout.sidebar-collapsed { + /* Adjust layout when sidebar is collapsed */ +} + +/* Hide toggle on mobile */ +@media (max-width: 768px) { + .sidebar-toggle { + display: none; + } +} + .dashboard-main { flex: 1; min-width: 0; @@ -1973,6 +2048,17 @@ body.dark-mode .modal-body select { height: 18px; } +/* Checkbox cell - click anywhere to toggle */ +.checkbox-cell { + cursor: pointer; + text-align: center; + transition: background-color 0.2s ease; +} + +.checkbox-cell:hover { + background-color: rgba(0, 255, 65, 0.15); +} + /* Responsive bulk actions */ @media (max-width: 768px) { .bulk-actions-toolbar { @@ -3243,3 +3329,519 @@ table td:nth-child(4) { overflow: hidden; text-overflow: ellipsis; } + +/* ===== TICKET LINK REFERENCES IN COMMENTS ===== */ +.ticket-link-ref { + color: var(--terminal-cyan); + text-decoration: none; + font-family: var(--font-mono); + padding: 0.1rem 0.3rem; + border-radius: 0; + background: rgba(0, 255, 255, 0.1); + transition: all 0.2s ease; +} + +.ticket-link-ref:hover { + color: var(--terminal-amber); + background: rgba(255, 176, 0, 0.15); + text-shadow: var(--glow-amber); +} + +/* ===== USER GROUPS DISPLAY ===== */ +.user-groups-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.group-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + background: rgba(0, 255, 255, 0.1); + border: 1px solid var(--terminal-cyan); + color: var(--terminal-cyan); + font-family: var(--font-mono); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.group-badge:hover { + background: rgba(0, 255, 255, 0.2); + text-shadow: 0 0 5px var(--terminal-cyan); +} + +/* ===== VISIBILITY SETTINGS ===== */ +.visibility-groups-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.visibility-groups-list label { + cursor: pointer; +} + +.visibility-groups-list label:hover .group-badge { + background: rgba(0, 255, 255, 0.2); + text-shadow: 0 0 5px var(--terminal-cyan); +} + +.visibility-group-checkbox { + width: 16px; + height: 16px; + cursor: pointer; +} + +.visibility-group-checkbox:checked + .group-badge { + background: var(--terminal-cyan); + color: var(--bg-primary); + font-weight: bold; +} + +.ticket-visibility-settings { + margin-top: 0.75rem; +} + +.visibility-groups-display { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* ===== INLINE TICKET PREVIEW POPUP ===== */ +.ticket-preview-popup { + position: absolute; + z-index: 10000; + width: 320px; + background: var(--bg-primary); + border: 2px solid var(--terminal-green); + box-shadow: 0 0 20px rgba(0, 255, 65, 0.3); + font-family: var(--font-mono); + padding: 0; + pointer-events: auto; +} + +.ticket-preview-popup::before { + content: '┌──────────────────────┐'; + display: block; + color: var(--terminal-green); + font-size: 0.7rem; + text-align: center; + padding: 0.25rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--terminal-green); +} + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--terminal-green); +} + +.preview-id { + color: var(--terminal-amber); + font-weight: bold; + text-shadow: var(--glow-amber); +} + +.preview-status { + padding: 0.2rem 0.5rem; + font-size: 0.75rem; + text-transform: uppercase; +} + +.preview-title { + padding: 0.75rem; + color: var(--terminal-green); + font-size: 0.95rem; + line-height: 1.4; + border-bottom: 1px solid var(--terminal-green); + word-break: break-word; +} + +.preview-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + padding: 0.75rem; + font-size: 0.8rem; + color: var(--terminal-green); +} + +.preview-meta strong { + color: var(--terminal-cyan); +} + +.preview-footer { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + color: var(--text-muted); + background: var(--bg-secondary); + border-top: 1px solid var(--terminal-green); +} + +/* ===== ENHANCED MOBILE RESPONSIVE STYLES ===== */ + +/* Table wrapper for horizontal scrolling */ +.table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin: 0 -0.5rem; + padding: 0 0.5rem; +} + +@media (max-width: 768px) { + /* Prevent iOS zoom on input focus */ + input[type="text"], + input[type="email"], + input[type="search"], + input[type="password"], + input[type="number"], + select, + textarea { + font-size: 16px !important; + } + + /* Minimum touch target size (44px) */ + .btn, + button, + .tab-btn, + .checkbox-cell, + .quick-action-btn, + input[type="checkbox"], + select { + min-height: 44px; + min-width: 44px; + } + + /* Better button spacing */ + .btn { + padding: 12px 16px; + margin: 4px 2px; + } + + /* Stack toolbar elements */ + .dashboard-toolbar { + flex-direction: column; + gap: 1rem; + padding: 1rem; + } + + .toolbar-left, + .toolbar-center, + .toolbar-right { + width: 100%; + justify-content: center; + flex-wrap: wrap; + } + + .toolbar-search { + flex-direction: column; + width: 100%; + } + + .toolbar-search input, + .toolbar-search button { + width: 100%; + margin: 4px 0; + } + + .search-box { + width: 100% !important; + max-width: none !important; + } + + /* Improve table readability */ + table { + font-size: 0.8rem; + } + + th, td { + padding: 8px 6px; + min-width: 60px; + } + + /* Hide less important columns on mobile */ + table th:nth-child(5), + table td:nth-child(5), + table th:nth-child(10), + table td:nth-child(10), + table th:nth-child(11), + table td:nth-child(11) { + display: none; + } + + /* Improve stats widgets */ + .stats-row { + flex-wrap: wrap; + } + + .stat-card { + flex: 1 1 calc(50% - 0.5rem); + min-width: 120px; + } + + /* Improve filter groups */ + .filter-group label { + min-height: 44px; + display: flex; + align-items: center; + padding: 0.5rem 0; + } + + .filter-group input[type="checkbox"] { + width: 20px; + height: 20px; + margin-right: 0.75rem; + } + + /* Improve pagination */ + .pagination { + flex-wrap: wrap; + justify-content: center; + } + + .pagination button { + min-width: 44px; + min-height: 44px; + padding: 8px 12px; + } + + /* Better modal sizing */ + .modal-content, + .settings-content { + width: 95%; + max-width: 95%; + max-height: 90vh; + overflow-y: auto; + } + + /* Hide preview on mobile (not useful with touch) */ + .ticket-preview-popup { + display: none !important; + } + + /* Improve quick actions */ + .quick-actions { + flex-direction: row; + gap: 4px; + } + + .quick-action-btn { + padding: 8px; + font-size: 1rem; + } +} + +/* ===== VIEW TOGGLE BUTTONS ===== */ +.view-toggle { + display: flex; + gap: 0; + border: 2px solid var(--terminal-green); +} + +.view-btn { + padding: 0.5rem 1rem; + background: var(--bg-primary); + color: var(--terminal-green); + border: none; + cursor: pointer; + font-size: 1.2rem; + min-width: 44px; + min-height: 44px; + transition: all 0.2s ease; +} + +.view-btn::before, +.view-btn::after { + content: ''; +} + +.view-btn:hover { + background: rgba(0, 255, 65, 0.15); +} + +.view-btn.active { + background: var(--terminal-green); + color: var(--bg-primary); +} + +/* ===== KANBAN BOARD STYLES ===== */ +.card-view-container { + margin-bottom: 2rem; +} + +.kanban-board { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + min-height: 500px; +} + +.kanban-column { + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); + display: flex; + flex-direction: column; + min-height: 400px; +} + +.kanban-column-header { + padding: 1rem; + border-bottom: 2px solid var(--terminal-green); + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.kanban-column-header.status-Open { + background: rgba(40, 167, 69, 0.1); + color: var(--status-open); + border-color: var(--status-open); +} + +.kanban-column-header.status-Pending { + background: rgba(156, 39, 176, 0.1); + color: var(--status-pending); + border-color: var(--status-pending); +} + +.kanban-column-header.status-In-Progress { + background: rgba(255, 193, 7, 0.1); + color: var(--status-in-progress); + border-color: var(--status-in-progress); +} + +.kanban-column-header.status-Closed { + background: rgba(220, 53, 69, 0.1); + color: var(--status-closed); + border-color: var(--status-closed); +} + +.column-title { + font-weight: bold; +} + +.column-count { + background: currentColor; + color: var(--bg-primary); + padding: 0.2rem 0.6rem; + font-size: 0.8rem; + font-weight: bold; +} + +.kanban-cards { + flex: 1; + padding: 0.5rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.kanban-card { + background: var(--bg-primary); + border: 1px solid var(--terminal-green); + padding: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: var(--font-mono); +} + +.kanban-card:hover { + border-color: var(--terminal-amber); + transform: translateX(4px); + box-shadow: -4px 0 0 var(--terminal-amber); +} + +.kanban-card.priority-1 { border-left: 4px solid var(--priority-1); } +.kanban-card.priority-2 { border-left: 4px solid var(--priority-2); } +.kanban-card.priority-3 { border-left: 4px solid var(--priority-3); } +.kanban-card.priority-4 { border-left: 4px solid var(--priority-4); } +.kanban-card.priority-5 { border-left: 4px solid var(--priority-5); } + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + font-size: 0.8rem; +} + +.card-id { + color: var(--terminal-cyan); +} + +.card-priority { + padding: 0.1rem 0.4rem; + font-size: 0.7rem; + font-weight: bold; +} + +.card-priority.p1 { background: var(--priority-1); color: white; } +.card-priority.p2 { background: var(--priority-2); color: black; } +.card-priority.p3 { background: var(--priority-3); color: white; } +.card-priority.p4 { background: var(--priority-4); color: black; } +.card-priority.p5 { background: var(--priority-5); color: white; } + +.card-title { + color: var(--terminal-green); + font-size: 0.9rem; + line-height: 1.3; + margin-bottom: 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.card-footer { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + color: var(--text-muted); +} + +.card-category { + background: rgba(0, 255, 65, 0.1); + padding: 0.1rem 0.4rem; +} + +.card-assignee { + width: 28px; + height: 28px; + background: var(--terminal-cyan); + color: var(--bg-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 0.7rem; +} + +/* Kanban responsive */ +@media (max-width: 1200px) { + .kanban-board { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .kanban-board { + grid-template-columns: 1fr; + } + + .kanban-column { + min-height: 200px; + } +} diff --git a/assets/css/ticket.css b/assets/css/ticket.css index e1e6740..61ca2e5 100644 --- a/assets/css/ticket.css +++ b/assets/css/ticket.css @@ -1499,3 +1499,116 @@ body.dark-mode .editable { background: rgba(0, 255, 65, 0.1); color: var(--terminal-amber); } + +/* ===== ENHANCED MOBILE RESPONSIVE - TICKET PAGE ===== */ +@media (max-width: 768px) { + /* Prevent iOS zoom on input focus */ + input[type="text"], + input[type="email"], + input[type="search"], + textarea, + select { + font-size: 16px !important; + } + + /* Touch-friendly button sizes */ + .btn, + button, + .tab-btn { + min-height: 44px; + padding: 12px 16px; + } + + /* Stack ticket metadata vertically */ + .ticket-metadata-fields { + grid-template-columns: 1fr !important; + gap: 1rem; + } + + .metadata-field { + width: 100%; + } + + /* Stack header controls */ + .ticket-subheader { + flex-direction: column; + gap: 1rem; + } + + .header-controls { + width: 100%; + flex-direction: column; + } + + .status-priority-group { + width: 100%; + margin-right: 0; + } + + .status-select { + width: 100%; + } + + /* Full width buttons */ + #editButton { + width: 100%; + } + + /* Improve comment form */ + .comment-form textarea { + width: 100%; + min-height: 120px; + } + + .comment-controls { + flex-direction: column; + gap: 1rem; + } + + .markdown-toggles { + flex-direction: column; + gap: 0.75rem; + } + + /* Improve assignment dropdown */ + .ticket-assignment { + flex-direction: column; + align-items: flex-start; + } + + .assignment-select { + width: 100%; + min-height: 44px; + } + + /* Full width tabs with better spacing */ + .ticket-tabs { + flex-wrap: wrap; + } + + .tab-btn { + flex: 1 1 auto; + text-align: center; + min-width: 100px; + } + + /* Improve upload zone */ + .upload-zone { + padding: 1.5rem; + } + + .upload-zone-content p { + font-size: 0.9rem; + } + + /* Improve timeline readability */ + .timeline-header { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .timeline-date { + margin-left: 0; + } +} diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 97331ec..53d1652 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -5,6 +5,34 @@ function escapeHtml(text) { return div.innerHTML; } +/** + * Toggle sidebar visibility on desktop + */ +function toggleSidebar() { + const sidebar = document.getElementById('dashboardSidebar'); + const layout = document.querySelector('.dashboard-layout'); + if (sidebar && layout) { + sidebar.classList.toggle('collapsed'); + layout.classList.toggle('sidebar-collapsed'); + // Store state in localStorage + const isCollapsed = sidebar.classList.contains('collapsed'); + localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false'); + } +} + +// Restore sidebar state on page load +document.addEventListener('DOMContentLoaded', function() { + const savedState = localStorage.getItem('sidebarCollapsed'); + if (savedState === 'true') { + const sidebar = document.getElementById('dashboardSidebar'); + const layout = document.querySelector('.dashboard-layout'); + if (sidebar && layout) { + sidebar.classList.add('collapsed'); + layout.classList.add('sidebar-collapsed'); + } + } +}); + // Main initialization document.addEventListener('DOMContentLoaded', function() { console.log('DOM loaded, initializing dashboard...'); @@ -480,6 +508,20 @@ function toggleSelectAll() { updateSelectionCount(); } +/** + * Toggle checkbox when clicking anywhere in the checkbox cell + */ +function toggleRowCheckbox(event, cell) { + // Prevent double-toggle if clicking directly on the checkbox + if (event.target.type === 'checkbox') return; + event.stopPropagation(); + const cb = cell.querySelector('.ticket-checkbox'); + if (cb) { + cb.checked = !cb.checked; + updateSelectionCount(); + } +} + function updateSelectionCount() { const checkboxes = document.querySelectorAll('.ticket-checkbox:checked'); const count = checkboxes.length; @@ -1355,6 +1397,231 @@ function performQuickAssign(ticketId) { }); } +// ======================================== +// KANBAN / CARD VIEW +// ======================================== + +/** + * Set the view mode (table or card) + */ +function setViewMode(mode) { + const tableView = document.querySelector('.ascii-frame-outer'); + const cardView = document.getElementById('cardView'); + const tableBtn = document.getElementById('tableViewBtn'); + const cardBtn = document.getElementById('cardViewBtn'); + + if (!tableView || !cardView) return; + + if (mode === 'card') { + tableView.style.display = 'none'; + cardView.style.display = 'block'; + tableBtn.classList.remove('active'); + cardBtn.classList.add('active'); + populateKanbanCards(); + } else { + tableView.style.display = 'block'; + cardView.style.display = 'none'; + tableBtn.classList.add('active'); + cardBtn.classList.remove('active'); + } + + // Store preference + localStorage.setItem('ticketViewMode', mode); +} + +/** + * Populate Kanban cards from table data + */ +function populateKanbanCards() { + const rows = document.querySelectorAll('tbody tr'); + const columns = { + 'Open': document.querySelector('.kanban-column[data-status="Open"] .kanban-cards'), + 'Pending': document.querySelector('.kanban-column[data-status="Pending"] .kanban-cards'), + 'In Progress': document.querySelector('.kanban-column[data-status="In Progress"] .kanban-cards'), + 'Closed': document.querySelector('.kanban-column[data-status="Closed"] .kanban-cards') + }; + + // Clear existing cards + Object.values(columns).forEach(col => { + if (col) col.innerHTML = ''; + }); + + const counts = { 'Open': 0, 'Pending': 0, 'In Progress': 0, 'Closed': 0 }; + const isAdmin = document.getElementById('selectAllCheckbox') !== null; + const offset = isAdmin ? 1 : 0; + + rows.forEach(row => { + const cells = row.querySelectorAll('td'); + if (cells.length < 6) return; // Skip empty rows + + const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || ''; + const priority = cells[1 + offset]?.textContent.trim() || ''; + const title = cells[2 + offset]?.textContent.trim() || ''; + const category = cells[3 + offset]?.textContent.trim() || ''; + const status = cells[5 + offset]?.textContent.trim() || ''; + const assignedTo = cells[7 + offset]?.textContent.trim() || 'Unassigned'; + + // Get initials for assignee + const initials = assignedTo === 'Unassigned' ? '?' : + assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); + + const column = columns[status]; + if (column) { + counts[status]++; + + const card = document.createElement('div'); + card.className = `kanban-card priority-${priority}`; + card.onclick = () => window.location.href = `/ticket/${ticketId}`; + card.innerHTML = ` +
+ #${escapeHtml(ticketId)} + P${priority} +
+
${escapeHtml(title)}
+ + `; + column.appendChild(card); + } + }); + + // Update column counts + Object.keys(counts).forEach(status => { + const header = document.querySelector(`.kanban-column[data-status="${status}"] .column-count`); + if (header) header.textContent = counts[status]; + }); +} + +// Restore view mode on page load +document.addEventListener('DOMContentLoaded', function() { + const savedMode = localStorage.getItem('ticketViewMode'); + if (savedMode === 'card') { + // Delay to ensure DOM is ready + setTimeout(() => setViewMode('card'), 100); + } +}); + +// ======================================== +// INLINE TICKET PREVIEW +// ======================================== + +let previewTimeout = null; +let currentPreview = null; + +function initTicketPreview() { + // Create preview element + const preview = document.createElement('div'); + preview.id = 'ticketPreview'; + preview.className = 'ticket-preview-popup'; + preview.style.display = 'none'; + document.body.appendChild(preview); + currentPreview = preview; + + // Add event listeners to ticket links + document.querySelectorAll('.ticket-link').forEach(link => { + link.addEventListener('mouseenter', showTicketPreview); + link.addEventListener('mouseleave', hideTicketPreview); + }); + + // Keep preview visible when hovering over it + preview.addEventListener('mouseenter', () => { + if (previewTimeout) { + clearTimeout(previewTimeout); + previewTimeout = null; + } + }); + + preview.addEventListener('mouseleave', hideTicketPreview); +} + +function showTicketPreview(event) { + const link = event.target.closest('.ticket-link'); + if (!link) return; + + // Clear any pending hide + if (previewTimeout) { + clearTimeout(previewTimeout); + } + + // Delay before showing + previewTimeout = setTimeout(() => { + const row = link.closest('tr'); + if (!row) return; + + // Extract data from the table row + const cells = row.querySelectorAll('td'); + const isAdmin = document.getElementById('selectAllCheckbox') !== null; + const offset = isAdmin ? 1 : 0; + + const ticketId = link.textContent.trim(); + const priority = cells[1 + offset]?.textContent.trim() || ''; + const title = cells[2 + offset]?.textContent.trim() || ''; + const category = cells[3 + offset]?.textContent.trim() || ''; + const type = cells[4 + offset]?.textContent.trim() || ''; + const status = cells[5 + offset]?.textContent.trim() || ''; + const createdBy = cells[6 + offset]?.textContent.trim() || ''; + const assignedTo = cells[7 + offset]?.textContent.trim() || ''; + + // Build preview content + currentPreview.innerHTML = ` +
+ #${escapeHtml(ticketId)} + ${escapeHtml(status)} +
+
${escapeHtml(title)}
+
+
Priority: P${escapeHtml(priority)}
+
Category: ${escapeHtml(category)}
+
Type: ${escapeHtml(type)}
+
Assigned: ${escapeHtml(assignedTo)}
+
+ + `; + + // Position the preview + const rect = link.getBoundingClientRect(); + const previewWidth = 320; + const previewHeight = 200; + + let left = rect.left + window.scrollX; + let top = rect.bottom + window.scrollY + 5; + + // Adjust if going off-screen + if (left + previewWidth > window.innerWidth) { + left = window.innerWidth - previewWidth - 20; + } + if (top + previewHeight > window.innerHeight + window.scrollY) { + top = rect.top + window.scrollY - previewHeight - 5; + } + + currentPreview.style.left = left + 'px'; + currentPreview.style.top = top + 'px'; + currentPreview.style.display = 'block'; + }, 300); +} + +function hideTicketPreview() { + if (previewTimeout) { + clearTimeout(previewTimeout); + } + previewTimeout = setTimeout(() => { + if (currentPreview) { + currentPreview.style.display = 'none'; + } + }, 100); +} + +// Initialize preview on page load +document.addEventListener('DOMContentLoaded', function() { + const hasTable = document.querySelector('table'); + const isTicketPage = window.location.pathname.includes('/ticket/'); + if (hasTable && !isTicketPage) { + initTicketPreview(); + } +}); + /** * Toggle export dropdown menu */ diff --git a/assets/js/markdown.js b/assets/js/markdown.js index 61fe6e1..54a20e1 100644 --- a/assets/js/markdown.js +++ b/assets/js/markdown.js @@ -13,6 +13,9 @@ function parseMarkdown(markdown) { .replace(//g, '>'); + // Ticket references (#123456789) - convert to clickable links + html = html.replace(/#(\d{9})\b/g, '#$1'); + // Code blocks (```code```) html = html.replace(/```([\s\S]*?)```/g, '
$1
'); diff --git a/controllers/TicketController.php b/controllers/TicketController.php index 5ac9296..0b67e60 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -15,8 +15,10 @@ class TicketController { private $workflowModel; private $templateModel; private $envVars; + private $conn; public function __construct($conn) { + $this->conn = $conn; $this->ticketModel = new TicketModel($conn); $this->commentModel = new CommentModel($conn); $this->auditLogModel = new AuditLogModel($conn); @@ -59,6 +61,13 @@ class TicketController { return; } + // Check visibility access + if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) { + header("HTTP/1.0 403 Forbidden"); + echo "Access denied: You do not have permission to view this ticket"; + return; + } + // Get comments for this ticket using CommentModel $comments = $this->commentModel->getCommentsByTicketId($id); @@ -82,18 +91,27 @@ class TicketController { // Check if form was submitted if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Handle visibility groups (comes as array from checkboxes) + $visibilityGroups = null; + if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) { + $visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups'])); + } + $ticketData = [ 'title' => $_POST['title'] ?? '', 'description' => $_POST['description'] ?? '', 'priority' => $_POST['priority'] ?? '4', 'category' => $_POST['category'] ?? 'General', - 'type' => $_POST['type'] ?? 'Issue' + 'type' => $_POST['type'] ?? 'Issue', + 'visibility' => $_POST['visibility'] ?? 'public', + 'visibility_groups' => $visibilityGroups ]; // Validate input if (empty($ticketData['title'])) { $error = "Title is required"; $templates = $this->templateModel->getAllTemplates(); + $conn = $this->conn; // Make $conn available to view include dirname(__DIR__) . '/views/CreateTicketView.php'; return; } @@ -116,12 +134,14 @@ class TicketController { } else { $error = $result['error']; $templates = $this->templateModel->getAllTemplates(); + $conn = $this->conn; // Make $conn available to view include dirname(__DIR__) . '/views/CreateTicketView.php'; return; } } else { // Get all templates for the template selector $templates = $this->templateModel->getAllTemplates(); + $conn = $this->conn; // Make $conn available to view // Display the create ticket form include dirname(__DIR__) . '/views/CreateTicketView.php'; diff --git a/index.php b/index.php index 30baaa9..907611c 100644 --- a/index.php +++ b/index.php @@ -219,6 +219,18 @@ switch (true) { include 'views/admin/AuditLogView.php'; break; + case $requestPath == '/admin/api-keys': + if (!$currentUser || !$currentUser['is_admin']) { + header("HTTP/1.0 403 Forbidden"); + echo 'Admin access required'; + break; + } + require_once 'models/ApiKeyModel.php'; + $apiKeyModel = new ApiKeyModel($conn); + $apiKeys = $apiKeyModel->getAllKeys(); + include 'views/admin/ApiKeysView.php'; + break; + case $requestPath == '/admin/user-activity': if (!$currentUser || !$currentUser['is_admin']) { header("HTTP/1.0 403 Forbidden"); diff --git a/migrations/008_ticket_visibility.sql b/migrations/008_ticket_visibility.sql new file mode 100644 index 0000000..e92e92f --- /dev/null +++ b/migrations/008_ticket_visibility.sql @@ -0,0 +1,15 @@ +-- Migration: Add ticket visibility levels +-- Run this migration to enable ticket visibility features + +-- Add visibility columns to tickets table +ALTER TABLE tickets +ADD COLUMN visibility ENUM('public', 'internal', 'confidential') DEFAULT 'public' AFTER type, +ADD COLUMN visibility_groups VARCHAR(500) DEFAULT NULL AFTER visibility; + +-- Create index for visibility filtering +CREATE INDEX idx_tickets_visibility ON tickets(visibility); + +-- Example usage: +-- Public: All authenticated users can see the ticket +-- Internal: Only users in specified groups can see the ticket (visibility_groups contains comma-separated group names) +-- Confidential: Only creator, assignee, and admins can see the ticket diff --git a/models/TicketModel.php b/models/TicketModel.php index 312eb03..ff2c4ae 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -261,8 +261,8 @@ class TicketModel { // Generate ticket ID (9-digit format with leading zeros) $ticket_id = sprintf('%09d', mt_rand(1, 999999999)); - $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; $stmt = $this->conn->prepare($sql); @@ -271,9 +271,22 @@ class TicketModel { $priority = $ticketData['priority'] ?? '4'; $category = $ticketData['category'] ?? 'General'; $type = $ticketData['type'] ?? 'Issue'; + $visibility = $ticketData['visibility'] ?? 'public'; + $visibilityGroups = $ticketData['visibility_groups'] ?? null; + + // Validate visibility + $allowedVisibilities = ['public', 'internal', 'confidential']; + if (!in_array($visibility, $allowedVisibilities)) { + $visibility = 'public'; + } + + // Clear visibility_groups if not internal + if ($visibility !== 'internal') { + $visibilityGroups = null; + } $stmt->bind_param( - "sssssssi", + "sssssssiss", $ticket_id, $ticketData['title'], $ticketData['description'], @@ -281,7 +294,9 @@ class TicketModel { $priority, $category, $type, - $createdBy + $createdBy, + $visibility, + $visibilityGroups ); if ($stmt->execute()) { @@ -407,4 +422,123 @@ class TicketModel { $stmt->close(); return $tickets; } + + /** + * Check if a user can access a ticket based on visibility settings + * + * @param array $ticket The ticket data + * @param array $user The user data (must include user_id, is_admin, groups) + * @return bool True if user can access the ticket + */ + public function canUserAccessTicket($ticket, $user) { + // Admins can access all tickets + if (!empty($user['is_admin'])) { + return true; + } + + $visibility = $ticket['visibility'] ?? 'public'; + + // Public tickets are accessible to all authenticated users + if ($visibility === 'public') { + return true; + } + + // Confidential tickets: only creator, assignee, and admins + if ($visibility === 'confidential') { + $userId = $user['user_id'] ?? null; + return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId); + } + + // Internal tickets: check if user is in any of the allowed groups + if ($visibility === 'internal') { + $allowedGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? ''))); + if (empty($allowedGroups)) { + return false; // No groups specified means no access + } + + $userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? ''))); + // Check if any user group matches any allowed group + return !empty(array_intersect($userGroups, $allowedGroups)); + } + + return false; + } + + /** + * Build visibility filter SQL for queries + * + * @param array $user The current user + * @return array ['sql' => string, 'params' => array, 'types' => string] + */ + public function getVisibilityFilter($user) { + // Admins see all tickets + if (!empty($user['is_admin'])) { + return ['sql' => '1=1', 'params' => [], 'types' => '']; + } + + $userId = $user['user_id'] ?? 0; + $userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? ''))); + + // Build the visibility filter + // 1. Public tickets + // 2. Confidential tickets where user is creator or assignee + // 3. Internal tickets where user's groups overlap with visibility_groups + $conditions = []; + $params = []; + $types = ''; + + // Public visibility + $conditions[] = "(t.visibility = 'public' OR t.visibility IS NULL)"; + + // Confidential - user is creator or assignee + $conditions[] = "(t.visibility = 'confidential' AND (t.created_by = ? OR t.assigned_to = ?))"; + $params[] = $userId; + $params[] = $userId; + $types .= 'ii'; + + // Internal - check group membership + if (!empty($userGroups)) { + $groupConditions = []; + foreach ($userGroups as $group) { + $groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))"; + $params[] = $group; + $types .= 's'; + } + $conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))"; + } + + return [ + 'sql' => '(' . implode(' OR ', $conditions) . ')', + 'params' => $params, + 'types' => $types + ]; + } + + /** + * Update ticket visibility settings + * + * @param int $ticketId + * @param string $visibility ('public', 'internal', 'confidential') + * @param string|null $visibilityGroups Comma-separated group names for 'internal' visibility + * @param int $updatedBy User ID + * @return bool + */ + public function updateVisibility($ticketId, $visibility, $visibilityGroups, $updatedBy) { + $allowedVisibilities = ['public', 'internal', 'confidential']; + if (!in_array($visibility, $allowedVisibilities)) { + $visibility = 'public'; + } + + // Clear visibility_groups if not internal + if ($visibility !== 'internal') { + $visibilityGroups = null; + } + + $sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId); + $result = $stmt->execute(); + $stmt->close(); + return $result; + } } \ No newline at end of file diff --git a/models/UserModel.php b/models/UserModel.php index 207cd16..5bce66d 100644 --- a/models/UserModel.php +++ b/models/UserModel.php @@ -266,4 +266,29 @@ class UserModel { $stmt->close(); return $users; } + + /** + * Get all distinct groups from all users + * Used for visibility group selection UI + * + * @return array Array of unique group names + */ + public function getAllGroups() { + $stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''"); + $stmt->execute(); + $result = $stmt->get_result(); + + $allGroups = []; + while ($row = $result->fetch_assoc()) { + $userGroups = array_filter(array_map('trim', explode(',', $row['groups']))); + $allGroups = array_merge($allGroups, $userGroups); + } + + $stmt->close(); + + // Return unique groups sorted alphabetically + $uniqueGroups = array_unique($allGroups); + sort($uniqueGroups); + return $uniqueGroups; + } } diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php index d460b06..a1aca43 100644 --- a/views/CreateTicketView.php +++ b/views/CreateTicketView.php @@ -166,7 +166,51 @@
- + +
Visibility Settings
+
+
+
+ + +

+ Controls who can view this ticket +

+
+ +
+
+ + +
+ +
Detailed Description
@@ -251,6 +295,18 @@ div.textContent = text; return div.innerHTML; } + + function toggleVisibilityGroups() { + const visibility = document.getElementById('visibility').value; + const groupsContainer = document.getElementById('visibilityGroupsContainer'); + if (visibility === 'internal') { + groupsContainer.style.display = 'block'; + } else { + groupsContainer.style.display = 'none'; + // Uncheck all group checkboxes when hiding + document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false); + } + } diff --git a/views/DashboardView.php b/views/DashboardView.php index cdf211e..942e7eb 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -92,6 +92,7 @@ 📝 Custom Fields 👥 User Activity 📜 Audit Log + 🔑 API Keys
@@ -125,7 +126,11 @@
-
+ + +
+
+ + +
+
@@ -395,15 +427,8 @@ function formatDetails($details, $actionType) { }); @@ -515,6 +540,23 @@ function formatDetails($details, $actionType) {
Role:
+ +
Groups:
+
+ + + + No groups assigned + +
diff --git a/views/admin/ApiKeysView.php b/views/admin/ApiKeysView.php new file mode 100644 index 0000000..314eaeb --- /dev/null +++ b/views/admin/ApiKeysView.php @@ -0,0 +1,245 @@ + + + + + + + API Keys - Admin + + + + + + + +
+
+ ← Dashboard + Admin: API Keys +
+
+ + + Admin + +
+
+ +
+ + + +
API Key Management
+
+ +
+

Generate New API Key

+
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + +
+

Existing API Keys

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameKey PrefixCreated ByCreated AtExpires AtLast UsedStatusActions
+ No API keys found. Generate one above. +
+ ... + + + + + + (Expired) + + + Never + + + + + + Active + + Revoked + + + + + + - + +
+
+ + +
+

API Usage

+

Include the API key in your requests using the Authorization header:

+
Authorization: Bearer YOUR_API_KEY
+

+ API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly. +

+
+
+
+ + + +