Fix views/controllers/router: command palette, create form, admin views

- Consolidate the duplicated command palette to a single overlay + init in
  the footer; fix New Ticket to route to /ticket/create (was a 404 /create);
  keep the CSP nonce and all commands
- TicketController create(): trim title, require a non-empty description,
  and honor the posted status (validated against the canonical list) instead
  of silently discarding it
- UserActivityView: 'Active Users' counts only users active in the selected
  range, not every registered user
- layout_footer/DashboardView: local esc() now escapes quotes so values used
  in HTML attributes can't break out
- TicketView: comments tab badge shows the true total, not just page one
- layout_header: gate the 'View activity log' link behind the admin flag
- index.php: validate /admin/user-activity date params; anchor the legacy
  /ticket.php route; align the audit action-type whitelist with the dropdown
- ApiKeysView: correct the external API sample to /create_ticket_api.php

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:15:40 -04:00
co-authored by Claude Opus 4.8
parent 113b7f9d3f
commit 27a5db8c85
9 changed files with 76 additions and 79 deletions
+20 -3
View File
@@ -93,19 +93,27 @@ class TicketController
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $_POST['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => $_POST['title'] ?? '',
'title' => trim($_POST['title'] ?? ''),
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
'status' => $status,
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
];
// Validate input
if (empty($ticketData['title'])) {
// Validate input (server-side; form is novalidate)
if ($ticketData['title'] === '') {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
@@ -114,6 +122,15 @@ class TicketController
return;
}
if (trim($ticketData['description']) === '') {
$error = "Description is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
+11 -5
View File
@@ -249,8 +249,11 @@ switch (true) {
$params = [];
$types = '';
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
// Mirrors AuditLogModel::VALID_ACTION_TYPES so every option offered by the
// audit-log filter dropdown is actually accepted here.
$allowedActionTypes = ['create','update','delete','view','security_event',
'login','logout','assign','unassign','comment','mention',
'revoke','attachment_upload','attachment_delete','bulk_update'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
@@ -335,9 +338,12 @@ switch (true) {
case $requestPath == '/admin/user-activity':
requireAdmin($currentUser);
// Validate date params (YYYY-MM-DD) like the audit-log route; fall back to defaults on garbage
$uaFrom = $_GET['date_from'] ?? '';
$uaTo = $_GET['date_to'] ?? '';
$dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
'to' => $_GET['date_to'] ?? date('Y-m-d')
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaFrom) ? $uaFrom : date('Y-m-d', strtotime('-30 days')),
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaTo) ? $uaTo : date('Y-m-d')
];
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
@@ -410,7 +416,7 @@ switch (true) {
header("Location: /");
exit;
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
case preg_match('/^\/ticket\.php$/', $requestPath) && isset($_GET['id']):
$legacyId = (string)$_GET['id'];
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
header("Location: /ticket/" . $legacyId);
+1 -1
View File
@@ -1317,7 +1317,7 @@ if (advForm) advForm.addEventListener('submit', function(e) {
var pLabels = { '1':'P1 — Critical', '2':'P2 — High', '3':'P3 — Medium', '4':'P4 — Low', '5':'P5 — Minimal' };
var dotClass = { 'Open':'lt-dot-up', 'In Progress':'lt-dot-warn', 'Pending':'lt-dot--orange', 'Closed':'lt-dot-idle' };
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function fmtAge(dateStr) {
var d = new Date(dateStr);
+2 -2
View File
@@ -461,8 +461,8 @@ include __DIR__ . '/layout_header.php';
<button type="button" class="lt-tab" id="comments-tab-btn"
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
Comments
<?php if (!empty($comments)) : ?>
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
<?php if ($totalComments > 0) : ?>
<span class="lt-badge lt-badge-sm"><?= (int)$totalComments ?></span>
<?php endif ?>
</button>
<button type="button" class="lt-tab" id="attachments-tab-btn"
+1 -1
View File
@@ -132,7 +132,7 @@ include __DIR__ . '/../../views/layout_header.php';
</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST https://your-instance/api/create_ticket.php \
<pre><code>curl -X POST https://your-instance/create_ticket_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
+7 -2
View File
@@ -29,9 +29,14 @@ include __DIR__ . '/../../views/layout_header.php';
<label class="lt-label" for="action_type">Action Type</label>
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
<option value="">All Actions</option>
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a) : ?>
<?php
// Mirrors AuditLogModel::VALID_ACTION_TYPES (the backend whitelist of loggable actions)
$auditActionTypes = ['create','update','delete','view','security_event',
'login','logout','assign','unassign','comment','mention',
'revoke','attachment_upload','attachment_delete','bulk_update'];
foreach ($auditActionTypes as $a) : ?>
<option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group" style="margin:0">
+13 -1
View File
@@ -43,11 +43,23 @@ include __DIR__ . '/../../views/layout_header.php';
<!-- Summary stats -->
<?php if (!empty($userStats)) : ?>
<?php
// "Active" = users with >=1 tracked action within the selected date range.
// The query LEFT JOINs from all users, so $userStats includes zero-activity users.
$activeUsers = 0;
foreach ($userStats as $_u) {
$_activity = ($_u['tickets_created'] ?? 0) + ($_u['tickets_resolved'] ?? 0)
+ ($_u['comments_added'] ?? 0) + ($_u['tickets_assigned'] ?? 0);
if ($_activity > 0) {
$activeUsers++;
}
}
?>
<div class="lt-stats-grid lt-mb-md">
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= count($userStats) ?></div>
<div class="lt-stat-value"><?= (int)$activeUsers ?></div>
<div class="lt-stat-label">Active Users</div>
</div>
</div>
+18 -2
View File
@@ -138,10 +138,13 @@
var themeBtn = document.getElementById('lt-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
// Command palette — global navigation commands available on all pages
// Command palette — single global instance (overlay DOM above; base.js binds Ctrl/Cmd+K)
var _cpCmds = [
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
{ id: 'filter-mine', group: 'Filter', icon: '◈', label: 'My Open Tickets', action: function() { window.location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
{ id: 'filter-unassigned', group: 'Filter', icon: '◌', label: 'Unassigned Tickets', action: function() { window.location.href = '/?assigned_to=unassigned'; } },
{ id: 'filter-critical', group: 'Filter', icon: '!', label: 'P1 Critical Tickets', action: function() { window.location.href = '/?priority=1'; } },
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
];
@@ -156,7 +159,20 @@
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
]);
<?php endif ?>
// Recently viewed tickets from localStorage
try {
var _recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
_recent.slice(0, 5).forEach(function(id) {
_cpCmds.push({ id: 'recent-' + id, group: 'Recent', icon: '◷', label: 'Ticket #' + id, tags: ['ticket'], action: function(tid) { return function() { window.location.href = '/ticket/' + tid; }; }(id) });
});
} catch (_e) { /* ignore malformed localStorage */ }
lt.cmdPalette.init(_cpCmds);
// Bind the header ⌘K trigger button (no inline onclick — CSP blocks inline handlers)
var _cmdTrigger = document.getElementById('lt-cmd-trigger');
if (_cmdTrigger) {
_cmdTrigger.addEventListener('click', function() { lt.cmdPalette.open(); });
}
}
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
@@ -194,7 +210,7 @@
return Math.floor(diff / 86400) + 'd ago';
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function renderNotifications(data) {
lt.notif.set(bell, data.unread_count || 0);
+3 -62
View File
@@ -196,7 +196,9 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div>
</div>
<div class="lt-notif-panel-footer">
<?php if ($_lt_isAdmin) : ?>
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
<?php endif; ?>
</div>
</div>
</div>
@@ -212,67 +214,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
</header><!-- /.lt-header -->
<!-- ── COMMAND PALETTE OVERLAY (Ctrl+K / ⌘K) ──────────────────── -->
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
<div id="lt-cmd-palette" class="lt-cmd-palette" role="combobox" aria-expanded="true" aria-haspopup="listbox">
<div class="lt-cmd-input-wrap">
<span aria-hidden="true" style="opacity:0.45;margin-right:0.4rem;font-size:0.9em">&#x2315;</span>
<input class="lt-cmd-input" type="text" placeholder="Type a command or search&hellip;"
autocomplete="off" spellcheck="false" aria-label="Command search" aria-autocomplete="list"
aria-controls="lt-cmd-results-list">
<kbd style="font-size:0.6rem;opacity:0.4;white-space:nowrap">ESC</kbd>
</div>
<div class="lt-cmd-results" id="lt-cmd-results-list" role="listbox"></div>
</div>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function() {
var isAdmin = <?= json_encode($_lt_isAdmin) ?>;
document.addEventListener('DOMContentLoaded', function() {
var commands = [
{ id: 'nav-dashboard', label: 'Dashboard', icon: '⌂', group: 'Navigate', action: function(){ location.href = '/'; } },
{ id: 'nav-new-ticket', label: 'New Ticket', icon: '+', group: 'Navigate', kbd: 'N', action: function(){ location.href = '/create'; } },
{ id: 'filter-mine', label: 'My Open Tickets', icon: '◈', group: 'Filter', action: function(){ location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
{ id: 'filter-unassigned', label: 'Unassigned Tickets', icon: '◌', group: 'Filter', action: function(){ location.href = '/?assigned_to=unassigned'; } },
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
];
if (isAdmin) {
[
{ id: 'admin-templates', label: 'Admin: Templates', icon: '▤', href: '/admin/templates' },
{ id: 'admin-workflow', label: 'Admin: Workflow', icon: '⇌', href: '/admin/workflow' },
{ id: 'admin-audit', label: 'Admin: Audit Log', icon: '📋', href: '/admin/audit-log' },
{ id: 'admin-api-keys', label: 'Admin: API Keys', icon: '🔑', href: '/admin/api-keys' },
{ id: 'admin-users', label: 'Admin: User Activity', icon: '👤', href: '/admin/user-activity' },
{ id: 'admin-recurring', label: 'Admin: Recurring', icon: '↻', href: '/admin/recurring-tickets' },
{ id: 'admin-fields', label: 'Admin: Custom Fields', icon: '⊞', href: '/admin/custom-fields' },
].forEach(function(c) {
commands.push({ id: c.id, label: c.label, icon: c.icon, group: 'Admin', action: function(href){ return function(){ location.href = href; }; }(c.href) });
});
}
// Inject recent ticket IDs from localStorage
try {
var recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
recent.slice(0, 5).forEach(function(id) {
commands.push({ id: 'recent-' + id, label: 'Ticket #' + id, icon: '◷', group: 'Recent', tags: ['ticket'], action: function(tid){ return function(){ location.href = '/ticket/' + tid; }; }(id) });
});
} catch(_) {}
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
// Bind the header ⌘K trigger here (no inline onclick — CSP blocks inline handlers)
var cmdTrigger = document.getElementById('lt-cmd-trigger');
if (cmdTrigger) {
cmdTrigger.addEventListener('click', function() {
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
});
}
});
// Keyboard shortcut: Ctrl+K / Cmd+K
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
}
});
})();
</script>
<!-- Command palette overlay + init live in layout_footer.php (single instance) -->
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">