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(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 = `
-
-
-
-
Navigation
-
- | J | Next ticket in list |
- | K | Previous ticket in list |
- | Enter | Open selected ticket |
- | G then D | Go to Dashboard |
-
-
Actions
-
- | N | New ticket |
- | C | Focus comment box |
- | Ctrl/Cmd+E | Toggle Edit Mode |
- | Ctrl/Cmd+S | Save Changes |
-
-
Quick Status (Ticket Page)
-
- | 1 | Set Open |
- | 2 | Set Pending |
- | 3 | Set In Progress |
- | 4 | Set Closed |
-
-
Other
-
- | Ctrl/Cmd+K | Focus Search |
- | ESC | Close 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 @@
+
+
+
╚╝
+
+
+
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 @@
+
+
+
╚╝
+
+
+
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
+
= htmlspecialchars($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', ENT_QUOTES, 'UTF-8') ?> — TDS v= htmlspecialchars($GLOBALS['config']['APP_VERSION'] ?? '1.2', ENT_QUOTES, 'UTF-8') ?>
-
+
+