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 <html> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
|
|||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
// Rotate token after successful validation; endpoints include it in their JSON response
|
||||||
|
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -47,3 +49,15 @@ $currentUser = $_SESSION['user'];
|
|||||||
$userId = $currentUser['user_id'];
|
$userId = $currentUser['user_id'];
|
||||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
$conn = Database::getConnection();
|
$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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -323,3 +323,24 @@ kbd {
|
|||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.lt-stats-grid { grid-template-columns: 1fr; }
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
+24
-23
@@ -288,13 +288,11 @@ function clearAllFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initTableSorting() {
|
function initTableSorting() {
|
||||||
const tableHeaders = document.querySelectorAll('th');
|
// Use the TDS lt.sortTable helper which manages aria-sort attributes correctly.
|
||||||
tableHeaders.forEach((header, index) => {
|
// Falls back to no-op if the table isn't present on this page.
|
||||||
header.addEventListener('click', () => {
|
if (window.lt && lt.sortTable && document.getElementById('tickets-table')) {
|
||||||
const table = header.closest('table');
|
lt.sortTable.init('tickets-table');
|
||||||
sortTable(table, index);
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSidebarFilters() {
|
function initSidebarFilters() {
|
||||||
@@ -1230,7 +1228,7 @@ function showTicketPreview(event) {
|
|||||||
currentPreview.innerHTML = `
|
currentPreview.innerHTML = `
|
||||||
<div class="preview-header">
|
<div class="preview-header">
|
||||||
<span class="preview-id">#${lt.escHtml(ticketId)}</span>
|
<span class="preview-id">#${lt.escHtml(ticketId)}</span>
|
||||||
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${lt.escHtml(status)}</span>
|
<span class="preview-status status-${lt.escHtml(status.replace(/\s+/g, '-'))}">${lt.escHtml(status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-title">${lt.escHtml(title)}</div>
|
<div class="preview-title">${lt.escHtml(title)}</div>
|
||||||
<div class="preview-meta">
|
<div class="preview-meta">
|
||||||
@@ -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...') {
|
function showLoadingOverlay(element, message = 'Loading...') {
|
||||||
// Remove existing overlay
|
const existing = element.querySelector('.lt-loading-overlay');
|
||||||
const existing = element.querySelector('.loading-overlay');
|
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'loading-overlay';
|
overlay.className = 'lt-loading-overlay';
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="loading-spinner"></div>
|
const spinner = document.createElement('div');
|
||||||
<div class="loading-text">${message}</div>
|
spinner.className = 'lt-spinner';
|
||||||
`;
|
|
||||||
element.classList.add('has-overlay');
|
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);
|
element.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide loading overlay
|
* Hide TDS spinner overlay
|
||||||
*/
|
*/
|
||||||
function hideLoadingOverlay(element) {
|
function hideLoadingOverlay(element) {
|
||||||
const overlay = element.querySelector('.loading-overlay');
|
const overlay = element.querySelector('.lt-loading-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
overlay.classList.add('loading-overlay--hiding');
|
|
||||||
setTimeout(() => {
|
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
element.classList.remove('has-overlay');
|
element.classList.remove('has-lt-overlay');
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = `
|
|
||||||
<div class="lt-modal lt-modal-sm">
|
|
||||||
<div class="lt-modal-header">
|
|
||||||
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
|
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="lt-modal-body">
|
|
||||||
<h4 class="kb-section-heading">Navigation</h4>
|
|
||||||
<table class="kb-shortcuts-table">
|
|
||||||
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
|
|
||||||
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
|
|
||||||
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
|
|
||||||
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
|
|
||||||
</table>
|
|
||||||
<h4 class="kb-section-heading">Actions</h4>
|
|
||||||
<table class="kb-shortcuts-table">
|
|
||||||
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
|
|
||||||
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
|
|
||||||
</table>
|
|
||||||
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
|
|
||||||
<table class="kb-shortcuts-table">
|
|
||||||
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
|
|
||||||
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
|
|
||||||
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
|
|
||||||
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
|
|
||||||
</table>
|
|
||||||
<h4 class="kb-section-heading">Other</h4>
|
|
||||||
<table class="kb-shortcuts-table no-margin">
|
|
||||||
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
|
|
||||||
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
|
|
||||||
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="lt-modal-footer">
|
|
||||||
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
lt.modal.open('keyboardHelpModal');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (!window.lt) return;
|
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() {
|
lt.keys.on('?', function() {
|
||||||
showKeyboardHelp();
|
if (window.lt) lt.modal.open('lt-keys-help');
|
||||||
});
|
});
|
||||||
|
|
||||||
// J: Next row
|
// J: Next row
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ if ($envVars) {
|
|||||||
|
|
||||||
// Global configuration
|
// Global configuration
|
||||||
$GLOBALS['config'] = [
|
$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
|
// Database settings
|
||||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||||
|
|||||||
@@ -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
|
// Simple router
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case $requestPath == '/' || $requestPath == '':
|
case $requestPath == '/' || $requestPath == '':
|
||||||
@@ -176,11 +185,7 @@ switch (true) {
|
|||||||
|
|
||||||
// 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']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
require_once 'models/RecurringTicketModel.php';
|
require_once 'models/RecurringTicketModel.php';
|
||||||
$recurringModel = new RecurringTicketModel($conn);
|
$recurringModel = new RecurringTicketModel($conn);
|
||||||
$recurringTickets = $recurringModel->getAll(true);
|
$recurringTickets = $recurringModel->getAll(true);
|
||||||
@@ -188,11 +193,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/custom-fields':
|
case $requestPath == '/admin/custom-fields':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
require_once 'models/CustomFieldModel.php';
|
require_once 'models/CustomFieldModel.php';
|
||||||
$fieldModel = new CustomFieldModel($conn);
|
$fieldModel = new CustomFieldModel($conn);
|
||||||
$customFields = $fieldModel->getAllDefinitions(null, false);
|
$customFields = $fieldModel->getAllDefinitions(null, false);
|
||||||
@@ -200,11 +201,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/workflow':
|
case $requestPath == '/admin/workflow':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||||
$workflows = [];
|
$workflows = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
@@ -214,11 +211,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/templates':
|
case $requestPath == '/admin/templates':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||||
$templates = [];
|
$templates = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
@@ -228,11 +221,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/audit-log':
|
case $requestPath == '/admin/audit-log':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||||
$perPage = 50;
|
$perPage = 50;
|
||||||
$offset = ($page - 1) * $perPage;
|
$offset = ($page - 1) * $perPage;
|
||||||
@@ -314,11 +303,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/api-keys':
|
case $requestPath == '/admin/api-keys':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
require_once 'models/ApiKeyModel.php';
|
require_once 'models/ApiKeyModel.php';
|
||||||
$apiKeyModel = new ApiKeyModel($conn);
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
$apiKeys = $apiKeyModel->getAllKeys();
|
$apiKeys = $apiKeyModel->getAllKeys();
|
||||||
@@ -326,11 +311,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/user-activity':
|
case $requestPath == '/admin/user-activity':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dateRange = [
|
$dateRange = [
|
||||||
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
||||||
@@ -407,9 +388,8 @@ switch (true) {
|
|||||||
exit;
|
exit;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 404 Not Found
|
http_response_code(404);
|
||||||
header("HTTP/1.0 404 Not Found");
|
include __DIR__ . '/views/error_404.php';
|
||||||
echo '404 Page Not Found';
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ class CsrfMiddleware {
|
|||||||
return hash_equals($_SESSION[self::$tokenName], $token);
|
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
|
* Check if token is expired
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
|
|||||||
// Content Security Policy - restricts where resources can be loaded from
|
// Content Security Policy - restricts where resources can be loaded from
|
||||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; 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
|
// Prevent clickjacking by disallowing framing
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: DENY");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'API Keys';
|
$pageTitle = 'API Keys';
|
||||||
$activeNav = 'admin-api-keys';
|
$activeNav = 'admin-api-keys';
|
||||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||||
$pageScripts = [];
|
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||||
include __DIR__ . '/../../views/layout_header.php';
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'Audit Log';
|
$pageTitle = 'Audit Log';
|
||||||
$activeNav = 'admin-audit-log';
|
$activeNav = 'admin-audit-log';
|
||||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||||
$pageScripts = [];
|
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||||
include __DIR__ . '/../../views/layout_header.php';
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'Custom Fields';
|
$pageTitle = 'Custom Fields';
|
||||||
$activeNav = 'admin-custom-fields';
|
$activeNav = 'admin-custom-fields';
|
||||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||||
$pageScripts = [];
|
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||||
include __DIR__ . '/../../views/layout_header.php';
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'Recurring Tickets';
|
$pageTitle = 'Recurring Tickets';
|
||||||
$activeNav = 'admin-recurring';
|
$activeNav = 'admin-recurring';
|
||||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||||
$pageScripts = [];
|
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||||
include __DIR__ . '/../../views/layout_header.php';
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'Templates';
|
$pageTitle = 'Templates';
|
||||||
$activeNav = 'admin-templates';
|
$activeNav = 'admin-templates';
|
||||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||||
$pageScripts = [];
|
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||||
include __DIR__ . '/../../views/layout_header.php';
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'User Activity';
|
$pageTitle = 'User Activity';
|
||||||
$activeNav = 'admin-user-activity';
|
$activeNav = 'admin-user-activity';
|
||||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||||
$pageScripts = [];
|
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||||
include __DIR__ . '/../../views/layout_header.php';
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'Workflow Designer';
|
$pageTitle = 'Workflow Designer';
|
||||||
$activeNav = 'admin-workflow';
|
$activeNav = 'admin-workflow';
|
||||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||||
$pageScripts = [];
|
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||||
include __DIR__ . '/../../views/layout_header.php';
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = '403 Forbidden';
|
||||||
|
$activeNav = '';
|
||||||
|
$pageStyles = [];
|
||||||
|
include __DIR__ . '/../views/layout_header.php';
|
||||||
|
?>
|
||||||
|
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header lt-text-danger">[ 403 ] ACCESS DENIED</div>
|
||||||
|
<div class="lt-section-body" style="text-align:center">
|
||||||
|
<p class="lt-text-muted lt-mb-md">You do not have permission to access this resource.</p>
|
||||||
|
<a href="/" class="lt-btn lt-btn-primary">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = '404 Not Found';
|
||||||
|
$activeNav = '';
|
||||||
|
$pageStyles = [];
|
||||||
|
include __DIR__ . '/../views/layout_header.php';
|
||||||
|
?>
|
||||||
|
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header lt-text-amber">[ 404 ] NOT FOUND</div>
|
||||||
|
<div class="lt-section-body" style="text-align:center">
|
||||||
|
<p class="lt-text-muted lt-mb-md">The page you requested does not exist.</p>
|
||||||
|
<a href="/" class="lt-btn lt-btn-primary">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
|
||||||
+17
-9
@@ -52,7 +52,7 @@
|
|||||||
<span class="lt-footer-sep">|</span>
|
<span class="lt-footer-sep">|</span>
|
||||||
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help" title="Show keyboard shortcuts (?)"><span class="lt-footer-key">[ ? ]</span> HELP</button>
|
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help" title="Show keyboard shortcuts (?)"><span class="lt-footer-key">[ ? ]</span> HELP</button>
|
||||||
</nav>
|
</nav>
|
||||||
<span aria-label="Application version">TINKER TICKETS — TDS v1.2</span>
|
<span aria-label="Application version"><?= htmlspecialchars($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', ENT_QUOTES, 'UTF-8') ?> — TDS v<?= htmlspecialchars($GLOBALS['config']['APP_VERSION'] ?? '1.2', ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<!-- LT INIT — boot animation + global UI init (base.js handles keys/nav automatically) -->
|
<!-- LT INIT — boot animation + global UI init (base.js handles keys/nav automatically) -->
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
if (window.lt) {
|
if (window.lt) {
|
||||||
lt.init({ bootName: 'TINKER TICKETS' });
|
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
|
||||||
|
|
||||||
// Theme toggle button
|
// Theme toggle button
|
||||||
var themeBtn = document.getElementById('lt-theme-btn');
|
var themeBtn = document.getElementById('lt-theme-btn');
|
||||||
@@ -155,19 +155,27 @@
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
|
||||||
|
if (window.lt && lt.api) {
|
||||||
|
['post', 'put', 'patch', 'delete'].forEach(function(method) {
|
||||||
|
if (typeof lt.api[method] !== 'function') return;
|
||||||
|
var _orig = lt.api[method];
|
||||||
|
lt.api[method] = function(url, body) {
|
||||||
|
return _orig.call(lt.api, url, body).then(function(data) {
|
||||||
|
if (data && data.csrf_token) window.CSRF_TOKEN = data.csrf_token;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Footer hint bar actions (keyboard help + settings — work on all pages)
|
// Footer hint bar actions (keyboard help + settings — work on all pages)
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
var btn = e.target.closest('[data-action]');
|
var btn = e.target.closest('[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
var action = btn.getAttribute('data-action');
|
var action = btn.getAttribute('data-action');
|
||||||
if (action === 'show-keyboard-help') {
|
if (action === 'show-keyboard-help') {
|
||||||
if (typeof showKeyboardHelp === 'function') {
|
if (window.lt) lt.modal.open('lt-keys-help');
|
||||||
showKeyboardHelp();
|
|
||||||
} else if (window.lt) {
|
|
||||||
var h = document.getElementById('lt-keys-help');
|
|
||||||
if (h) lt.modal.open('lt-keys-help');
|
|
||||||
else lt.toast.info('Keyboard shortcuts: ESC=close Ctrl+K=search ?=this help');
|
|
||||||
}
|
|
||||||
} else if (action === 'open-settings' || action === 'open-settings-modal') {
|
} else if (action === 'open-settings' || action === 'open-settings-modal') {
|
||||||
if (typeof openSettingsModal === 'function') {
|
if (typeof openSettingsModal === 'function') {
|
||||||
openSettingsModal();
|
openSettingsModal();
|
||||||
|
|||||||
+13
-10
@@ -17,19 +17,22 @@
|
|||||||
$_lt_user = $GLOBALS['currentUser'] ?? [];
|
$_lt_user = $GLOBALS['currentUser'] ?? [];
|
||||||
$_lt_isAdmin = !empty($_lt_user['is_admin']);
|
$_lt_isAdmin = !empty($_lt_user['is_admin']);
|
||||||
$_lt_navActive = $activeNav ?? 'dashboard';
|
$_lt_navActive = $activeNav ?? 'dashboard';
|
||||||
|
$_lt_appName = $GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS';
|
||||||
|
$_lt_subtitle = $GLOBALS['config']['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure';
|
||||||
|
$_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#030508">
|
<meta name="theme-color" content="#030508">
|
||||||
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> — Tinker Tickets</title>
|
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> — <?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></title>
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/assets/css/base.css">
|
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
|
||||||
<?php if (!empty($pageStyles)): ?>
|
<?php if (!empty($pageStyles)): ?>
|
||||||
<?php foreach ($pageStyles as $_lt_css): ?>
|
<?php foreach ($pageStyles as $_lt_css): ?>
|
||||||
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
|
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
@@ -37,8 +40,8 @@ $_lt_navActive = $activeNav ?? 'dashboard';
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||||
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
|
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js"></script>
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js?v=<?= $_lt_assetVer ?>"></script>
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js"></script>
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js?v=<?= $_lt_assetVer ?>"></script>
|
||||||
<!-- Inline JS globals (CSRF, timezone, user) available immediately -->
|
<!-- Inline JS globals (CSRF, timezone, user) available immediately -->
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||||
@@ -58,14 +61,14 @@ $_lt_navActive = $activeNav ?? 'dashboard';
|
|||||||
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
|
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
|
||||||
|
|
||||||
<!-- BOOT OVERLAY — controlled by lt.boot() in base.js; shown once per session -->
|
<!-- BOOT OVERLAY — controlled by lt.boot() in base.js; shown once per session -->
|
||||||
<div id="lt-boot" class="lt-boot-overlay" data-app-name="TINKER TICKETS" style="display:none" aria-hidden="true">
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>" style="display:none" aria-hidden="true">
|
||||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE NAV DRAWER — matches web_template structure exactly -->
|
<!-- MOBILE NAV DRAWER — matches web_template structure exactly -->
|
||||||
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
|
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
|
||||||
<div class="lt-nav-drawer-header">
|
<div class="lt-nav-drawer-header">
|
||||||
<span class="lt-brand-title">TINKER TICKETS</span>
|
<span class="lt-brand-title"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">✕</button>
|
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
|
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
|
||||||
@@ -108,10 +111,10 @@ $_lt_navActive = $activeNav ?? 'dashboard';
|
|||||||
<div class="lt-brand">
|
<div class="lt-brand">
|
||||||
<a href="/"
|
<a href="/"
|
||||||
class="lt-brand-title lt-glitch"
|
class="lt-brand-title lt-glitch"
|
||||||
data-text="TINKER TICKETS"
|
data-text="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>"
|
||||||
style="text-decoration:none"
|
style="text-decoration:none"
|
||||||
aria-label="Tinker Tickets home">TINKER TICKETS</a>
|
aria-label="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?> home"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></a>
|
||||||
<span class="lt-brand-subtitle">LotusGuild Infrastructure</span>
|
<span class="lt-brand-subtitle"><?= htmlspecialchars($_lt_subtitle, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop navigation -->
|
<!-- Desktop navigation -->
|
||||||
|
|||||||
Reference in New Issue
Block a user