From 2e450dc01d6554515b4b3382852a5ab4d1802df7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 29 Mar 2026 17:02:40 -0400 Subject: [PATCH] Apply web_template gap analysis improvements (P1-P3) P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates after successful validation and stores in $GLOBALS['_new_csrf_token']; add apiRespond() helper to append token to responses; lt.api interceptor in layout_footer.php auto-updates window.CSRF_TOKEN from responses P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D) P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js; '?' key now routes to static #lt-keys-help modal in footer P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get ?v= cache-busting in layout_header.php P2-C: Add data-theme="dark" to tag to prevent FOUC on light-mode users P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml() P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from base.css; add .lt-loading-overlay wrapper CSS to dashboard.css P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer use config values instead of hardcoded strings P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort Co-Authored-By: Claude Sonnet 4.6 --- api/bootstrap.php | 14 ++++++ assets/css/dashboard.css | 21 +++++++++ assets/js/dashboard.js | 49 ++++++++++---------- assets/js/keyboard-shortcuts.js | 58 +----------------------- config/config.php | 8 ++++ index.php | 56 ++++++++--------------- middleware/CsrfMiddleware.php | 12 +++++ middleware/SecurityHeadersMiddleware.php | 2 +- views/admin/ApiKeysView.php | 2 +- views/admin/AuditLogView.php | 2 +- views/admin/CustomFieldsView.php | 2 +- views/admin/RecurringTicketsView.php | 2 +- views/admin/TemplatesView.php | 2 +- views/admin/UserActivityView.php | 2 +- views/admin/WorkflowDesignerView.php | 2 +- views/error_403.php | 18 ++++++++ views/error_404.php | 18 ++++++++ views/layout_footer.php | 26 +++++++---- views/layout_header.php | 23 ++++++---- 19 files changed, 174 insertions(+), 145 deletions(-) create mode 100644 views/error_403.php create mode 100644 views/error_404.php diff --git a/api/bootstrap.php b/api/bootstrap.php index ecac00e..0f67234 100644 --- a/api/bootstrap.php +++ b/api/bootstrap.php @@ -38,6 +38,8 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) { echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); exit; } + // Rotate token after successful validation; endpoints include it in their JSON response + $GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken(); } header('Content-Type: application/json'); @@ -47,3 +49,15 @@ $currentUser = $_SESSION['user']; $userId = $currentUser['user_id']; $isAdmin = $currentUser['is_admin'] ?? false; $conn = Database::getConnection(); + +/** + * Output a JSON response, appending the rotated CSRF token so the + * client-side lt.api interceptor can update window.CSRF_TOKEN. + */ +function apiRespond(array $data): void { + if (!empty($GLOBALS['_new_csrf_token'])) { + $data['csrf_token'] = $GLOBALS['_new_csrf_token']; + } + echo json_encode($data); + exit; +} diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 522c625..33edadc 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -323,3 +323,24 @@ kbd { @media (max-width: 480px) { .lt-stats-grid { grid-template-columns: 1fr; } } + +/* Loading overlay — wraps lt-spinner for element-level loading states */ +.has-lt-overlay { position: relative; } +.lt-loading-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: rgba(var(--bg-primary-rgb, 10,10,10), 0.75); + z-index: 10; + border-radius: inherit; +} +.lt-loading-text { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index f8976ed..f3fe0fc 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -288,13 +288,11 @@ function clearAllFilters() { } function initTableSorting() { - const tableHeaders = document.querySelectorAll('th'); - tableHeaders.forEach((header, index) => { - header.addEventListener('click', () => { - const table = header.closest('table'); - sortTable(table, index); - }); - }); + // Use the TDS lt.sortTable helper which manages aria-sort attributes correctly. + // Falls back to no-op if the table isn't present on this page. + if (window.lt && lt.sortTable && document.getElementById('tickets-table')) { + lt.sortTable.init('tickets-table'); + } } function initSidebarFilters() { @@ -1230,7 +1228,7 @@ function showTicketPreview(event) { currentPreview.innerHTML = `
#${lt.escHtml(ticketId)} - ${lt.escHtml(status)} + ${lt.escHtml(status)}
${lt.escHtml(title)}
@@ -1330,34 +1328,37 @@ function exportSelectedTickets(format) { /** - * Show loading overlay on element + * Show TDS spinner overlay on an element. + * Uses lt-spinner + lt-loading-text from base.css. */ function showLoadingOverlay(element, message = 'Loading...') { - // Remove existing overlay - const existing = element.querySelector('.loading-overlay'); + const existing = element.querySelector('.lt-loading-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); - overlay.className = 'loading-overlay'; - overlay.innerHTML = ` -
-
${message}
- `; - element.classList.add('has-overlay'); + overlay.className = 'lt-loading-overlay'; + + const spinner = document.createElement('div'); + spinner.className = 'lt-spinner'; + + const text = document.createElement('div'); + text.className = 'lt-loading-text'; + text.textContent = message; + + overlay.appendChild(spinner); + overlay.appendChild(text); + element.classList.add('has-lt-overlay'); element.appendChild(overlay); } /** - * Hide loading overlay + * Hide TDS spinner overlay */ function hideLoadingOverlay(element) { - const overlay = element.querySelector('.loading-overlay'); + const overlay = element.querySelector('.lt-loading-overlay'); if (overlay) { - overlay.classList.add('loading-overlay--hiding'); - setTimeout(() => { - overlay.remove(); - element.classList.remove('has-overlay'); - }, 300); + overlay.remove(); + element.classList.remove('has-lt-overlay'); } } diff --git a/assets/js/keyboard-shortcuts.js b/assets/js/keyboard-shortcuts.js index 4acd0cb..e1d6d34 100644 --- a/assets/js/keyboard-shortcuts.js +++ b/assets/js/keyboard-shortcuts.js @@ -26,60 +26,6 @@ function navigateTableRow(direction) { } } -function showKeyboardHelp() { - if (document.getElementById('keyboardHelpModal')) return; - - const modal = document.createElement('div'); - modal.id = 'keyboardHelpModal'; - modal.className = 'lt-modal-overlay'; - modal.setAttribute('aria-hidden', 'true'); - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle'); - modal.innerHTML = ` -
-
- KEYBOARD SHORTCUTS - -
-
-

Navigation

- - - - - -
JNext ticket in list
KPrevious ticket in list
EnterOpen selected ticket
G then DGo to Dashboard
-

Actions

- - - - - -
NNew ticket
CFocus comment box
Ctrl/Cmd+EToggle Edit Mode
Ctrl/Cmd+SSave Changes
-

Quick Status (Ticket Page)

- - - - - -
1Set Open
2Set Pending
3Set In Progress
4Set Closed
-

Other

- - - - -
Ctrl/Cmd+KFocus Search
ESCClose Modal / Cancel
?Show This Help
-
- -
- `; - document.body.appendChild(modal); - lt.modal.open('keyboardHelpModal'); -} - document.addEventListener('DOMContentLoaded', function() { if (!window.lt) return; @@ -101,9 +47,9 @@ document.addEventListener('DOMContentLoaded', function() { } }); - // ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal) + // ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer lt.keys.on('?', function() { - showKeyboardHelp(); + if (window.lt) lt.modal.open('lt-keys-help'); }); // J: Next row diff --git a/config/config.php b/config/config.php index 2a4132c..625c837 100644 --- a/config/config.php +++ b/config/config.php @@ -20,6 +20,14 @@ if ($envVars) { // Global configuration $GLOBALS['config'] = [ + // Application identity + 'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS', + 'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure', + 'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2', + + // Asset cache-busting version string (bump when CSS/JS changes) + 'ASSET_VERSION' => $envVars['ASSET_VERSION'] ?? '20260329', + // Database settings 'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost', 'DB_USER' => $envVars['DB_USER'] ?? 'root', diff --git a/index.php b/index.php index 305f978..ec3c54c 100644 --- a/index.php +++ b/index.php @@ -53,6 +53,15 @@ if (!str_starts_with($requestPath, '/api/')) { } } +// Helper: require admin or render styled 403 and exit +function requireAdmin(?array $user): void { + if (!$user || empty($user['is_admin'])) { + http_response_code(403); + include __DIR__ . '/views/error_403.php'; + exit; + } +} + // Simple router switch (true) { case $requestPath == '/' || $requestPath == '': @@ -176,11 +185,7 @@ switch (true) { // Admin Routes - require admin privileges case $requestPath == '/admin/recurring-tickets': - if (!$currentUser || !$currentUser['is_admin']) { - header("HTTP/1.0 403 Forbidden"); - echo 'Admin access required'; - break; - } + requireAdmin($currentUser); require_once 'models/RecurringTicketModel.php'; $recurringModel = new RecurringTicketModel($conn); $recurringTickets = $recurringModel->getAll(true); @@ -188,11 +193,7 @@ switch (true) { break; case $requestPath == '/admin/custom-fields': - if (!$currentUser || !$currentUser['is_admin']) { - header("HTTP/1.0 403 Forbidden"); - echo 'Admin access required'; - break; - } + requireAdmin($currentUser); require_once 'models/CustomFieldModel.php'; $fieldModel = new CustomFieldModel($conn); $customFields = $fieldModel->getAllDefinitions(null, false); @@ -200,11 +201,7 @@ switch (true) { break; case $requestPath == '/admin/workflow': - if (!$currentUser || !$currentUser['is_admin']) { - header("HTTP/1.0 403 Forbidden"); - echo 'Admin access required'; - break; - } + requireAdmin($currentUser); $result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status"); $workflows = []; while ($row = $result->fetch_assoc()) { @@ -214,11 +211,7 @@ switch (true) { break; case $requestPath == '/admin/templates': - if (!$currentUser || !$currentUser['is_admin']) { - header("HTTP/1.0 403 Forbidden"); - echo 'Admin access required'; - break; - } + requireAdmin($currentUser); $result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name"); $templates = []; while ($row = $result->fetch_assoc()) { @@ -228,11 +221,7 @@ switch (true) { break; case $requestPath == '/admin/audit-log': - if (!$currentUser || !$currentUser['is_admin']) { - header("HTTP/1.0 403 Forbidden"); - echo 'Admin access required'; - break; - } + requireAdmin($currentUser); $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; $perPage = 50; $offset = ($page - 1) * $perPage; @@ -314,11 +303,7 @@ switch (true) { break; case $requestPath == '/admin/api-keys': - if (!$currentUser || !$currentUser['is_admin']) { - header("HTTP/1.0 403 Forbidden"); - echo 'Admin access required'; - break; - } + requireAdmin($currentUser); require_once 'models/ApiKeyModel.php'; $apiKeyModel = new ApiKeyModel($conn); $apiKeys = $apiKeyModel->getAllKeys(); @@ -326,11 +311,7 @@ switch (true) { break; case $requestPath == '/admin/user-activity': - if (!$currentUser || !$currentUser['is_admin']) { - header("HTTP/1.0 403 Forbidden"); - echo 'Admin access required'; - break; - } + requireAdmin($currentUser); $dateRange = [ 'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')), @@ -407,9 +388,8 @@ switch (true) { exit; default: - // 404 Not Found - header("HTTP/1.0 404 Not Found"); - echo '404 Page Not Found'; + http_response_code(404); + include __DIR__ . '/views/error_404.php'; break; } diff --git a/middleware/CsrfMiddleware.php b/middleware/CsrfMiddleware.php index c089257..22ba9f1 100644 --- a/middleware/CsrfMiddleware.php +++ b/middleware/CsrfMiddleware.php @@ -44,6 +44,18 @@ class CsrfMiddleware { return hash_equals($_SESSION[self::$tokenName], $token); } + /** + * Rotate the CSRF token after a successful validated POST. + * Call this after validateToken() returns true, then include + * the new token in the JSON response as 'csrf_token' so the + * client can update window.CSRF_TOKEN for subsequent requests. + * + * @return string The new token + */ + public static function rotateToken(): string { + return self::generateToken(); + } + /** * Check if token is expired */ diff --git a/middleware/SecurityHeadersMiddleware.php b/middleware/SecurityHeadersMiddleware.php index 3c920ce..e2b51f2 100644 --- a/middleware/SecurityHeadersMiddleware.php +++ b/middleware/SecurityHeadersMiddleware.php @@ -28,7 +28,7 @@ class SecurityHeadersMiddleware { // 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 // 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}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';"); + header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';"); // Prevent clickjacking by disallowing framing header("X-Frame-Options: DENY"); diff --git a/views/admin/ApiKeysView.php b/views/admin/ApiKeysView.php index ffc01c4..6b2dd6b 100644 --- a/views/admin/ApiKeysView.php +++ b/views/admin/ApiKeysView.php @@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $pageTitle = 'API Keys'; $activeNav = 'admin-api-keys'; $pageStyles = ['/assets/css/dashboard.css?v=20260327']; -$pageScripts = []; +$pageScripts = ['/assets/js/keyboard-shortcuts.js']; include __DIR__ . '/../../views/layout_header.php'; ?> diff --git a/views/admin/AuditLogView.php b/views/admin/AuditLogView.php index 678ab60..8011330 100644 --- a/views/admin/AuditLogView.php +++ b/views/admin/AuditLogView.php @@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $pageTitle = 'Audit Log'; $activeNav = 'admin-audit-log'; $pageStyles = ['/assets/css/dashboard.css?v=20260327']; -$pageScripts = []; +$pageScripts = ['/assets/js/keyboard-shortcuts.js']; include __DIR__ . '/../../views/layout_header.php'; ?> diff --git a/views/admin/CustomFieldsView.php b/views/admin/CustomFieldsView.php index 357c4d7..08cebe0 100644 --- a/views/admin/CustomFieldsView.php +++ b/views/admin/CustomFieldsView.php @@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $pageTitle = 'Custom Fields'; $activeNav = 'admin-custom-fields'; $pageStyles = ['/assets/css/dashboard.css?v=20260327']; -$pageScripts = []; +$pageScripts = ['/assets/js/keyboard-shortcuts.js']; include __DIR__ . '/../../views/layout_header.php'; ?> diff --git a/views/admin/RecurringTicketsView.php b/views/admin/RecurringTicketsView.php index b635662..4e9e87f 100644 --- a/views/admin/RecurringTicketsView.php +++ b/views/admin/RecurringTicketsView.php @@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $pageTitle = 'Recurring Tickets'; $activeNav = 'admin-recurring'; $pageStyles = ['/assets/css/dashboard.css?v=20260327']; -$pageScripts = []; +$pageScripts = ['/assets/js/keyboard-shortcuts.js']; include __DIR__ . '/../../views/layout_header.php'; ?> diff --git a/views/admin/TemplatesView.php b/views/admin/TemplatesView.php index a249381..d97b42e 100644 --- a/views/admin/TemplatesView.php +++ b/views/admin/TemplatesView.php @@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $pageTitle = 'Templates'; $activeNav = 'admin-templates'; $pageStyles = ['/assets/css/dashboard.css?v=20260327']; -$pageScripts = []; +$pageScripts = ['/assets/js/keyboard-shortcuts.js']; include __DIR__ . '/../../views/layout_header.php'; ?> diff --git a/views/admin/UserActivityView.php b/views/admin/UserActivityView.php index dac845d..d11f21b 100644 --- a/views/admin/UserActivityView.php +++ b/views/admin/UserActivityView.php @@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $pageTitle = 'User Activity'; $activeNav = 'admin-user-activity'; $pageStyles = ['/assets/css/dashboard.css?v=20260327']; -$pageScripts = []; +$pageScripts = ['/assets/js/keyboard-shortcuts.js']; include __DIR__ . '/../../views/layout_header.php'; ?> diff --git a/views/admin/WorkflowDesignerView.php b/views/admin/WorkflowDesignerView.php index ffcc390..9183fd6 100644 --- a/views/admin/WorkflowDesignerView.php +++ b/views/admin/WorkflowDesignerView.php @@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $pageTitle = 'Workflow Designer'; $activeNav = 'admin-workflow'; $pageStyles = ['/assets/css/dashboard.css?v=20260327']; -$pageScripts = []; +$pageScripts = ['/assets/js/keyboard-shortcuts.js']; include __DIR__ . '/../../views/layout_header.php'; ?> diff --git a/views/error_403.php b/views/error_403.php new file mode 100644 index 0000000..f6d2ec7 --- /dev/null +++ b/views/error_403.php @@ -0,0 +1,18 @@ + +
+ +
[ 403 ] ACCESS DENIED
+
+

You do not have permission to access this resource.

+ ← Dashboard +
+
+ diff --git a/views/error_404.php b/views/error_404.php new file mode 100644 index 0000000..4d9450c --- /dev/null +++ b/views/error_404.php @@ -0,0 +1,18 @@ + +
+ +
[ 404 ] NOT FOUND
+
+

The page you requested does not exist.

+ ← Dashboard +
+
+ diff --git a/views/layout_footer.php b/views/layout_footer.php index 3961094..3bf8da2 100644 --- a/views/layout_footer.php +++ b/views/layout_footer.php @@ -52,7 +52,7 @@ | - TINKER TICKETS — TDS v1.2 + — TDS v - + +