137 Commits

Author SHA1 Message Date
jared 499060795e Use drive serial for dedup hash; update title/priority on worsened conditions
Two changes to the external ticket API:

1. Serial-based dedup: generateTicketHash() now uses the `serial` field
   from the hwmonDaemon payload as the stable drive identifier instead of
   extracting /dev/sdX from the title. Device path is kept as a fallback
   for payloads without a serial field (backwards compatible).
   Hash key renamed from `device` to `drive` to reflect this.

2. Active-ticket updates: when a duplicate is detected and the ticket is
   still open, the API now compares the incoming title and priority against
   the existing ticket. If the title changed or priority escalated (lower
   number), the ticket is updated and a comment is added explaining what
   changed. Previously the API silently returned "Duplicate ticket" with
   no update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:55:15 -04:00
jared fe9c6b3ee0 Rewrite create_ticket_api.php dedup logic: reopen closed tickets on recurrence
Instead of crashing (PHP 8.2 TypeError) or silently failing on duplicate hash,
the API now:
- Checks for any existing ticket with the same hash (no 24h limit)
- If open/pending/in-progress: returns Duplicate ticket with existing ID
- If closed: reopens the ticket, posts a recurrence comment, returns action=reopened
- If new: creates the ticket as before
- Wraps INSERT in try/catch for mysqli_sql_exception to handle race conditions
  gracefully when multiple nodes POST simultaneously

Also improves the hash function:
- Ceph issues now include a subtype (bluestore_slow, clock_skew, osd_down, etc.)
  so different Ceph warnings get distinct tickets instead of colliding
- LXC storage issues include the container ID so each container gets its own ticket
- Fixed potential null-subject issue in preg_match for missing titles
- Added early input validation (400 + JSON error) before any processing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:40:36 -04:00
jared 570b1749da Fix PHP 8.2 TypeError crash in create_ticket_api.php on missing title
generateTicketHash() passed $data['title'] to preg_match() before any
input validation. In PHP 8.2, preg_match() with null subject throws
TypeError, causing HTTP 500 with empty body. hwmonDaemon saw this as
"Expecting value: line 1 column 1 (char 0)" and failed to create tickets
on all nodes.

Moved input validation before the hash call: missing or empty title now
returns HTTP 400 with proper JSON error instead of crashing. Also removed
the redundant late URL-encoded fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:07:11 -04:00
jared cc509874e7 Fix incomplete HTML escaping in reply textarea (ticket.js)
Line 1575 used .replace(/</g, '&lt;').replace(/>/g, '&gt;') to set
the comment-raw edit textarea content, missing '&' → '&amp;'. Replaced
with lt.escHtml() which escapes all five special HTML characters (&, <,
>, ", ') consistently with the rest of the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:21:54 -04:00
jared 6e1ae01cac Fix recurring ticket schedule edge cases in API and model
- manage_recurring.php calculateNextRun(): expand monthly cap from 28→31
  with proper last-day-of-month clamping (matches model fix); use split
  with ':00' append to handle malformed time strings without crashing;
  fix weekly day array to start at index 1 (not 0) so day=0 never maps
  to empty string and blows up DateTime
- RecurringTicketModel::calculateNextRunTime(): same weekly day array fix
  (start at index 1) to eliminate '' → DateTime exception on day=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:16:41 -04:00
jared c3ab5c5716 Fix double URL-encoding of Matrix user ID in SynapseHelper
rawurlencode($username) was called on line 38 (encoding the username),
then rawurlencode($matrixId) was called on line 39 encoding the already-
encoded string — causing %20 to become %2520 for usernames with special
characters. Fixed by building $matrixId with the plain username and only
encoding the full Matrix ID once in the URL path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:12:02 -04:00
jared 538baadd57 Add comment skeleton loaders, workflow validation, monthly schedule fix
- TicketView.php: Show 3 lt-skeleton-card placeholders in the comment list
  while "Load more" fetches; skeletons are removed on resolve or error
- ticket.css: Add .comment-skeleton margin spacing
- WorkflowDesignerView.php + manage_workflows.php: Prevent creating/editing
  status transitions where from_status === to_status (client + server check)
- RecurringTicketsView.php: Expand monthly day picker from 28 to 31 days
  (days 29-31 labelled "last day in short months")
- RecurringTicketModel.php: Clamp monthly schedule day to last day of target
  month using format('t') instead of hard-capping at 28

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:09:53 -04:00
jared fbda618fbb Fix path traversal, closed-connection, and ticket ID validation bugs
- download_attachment.php: path traversal check used strpos() without
  trailing DIRECTORY_SEPARATOR, allowing /uploads_evil/* to pass when
  upload dir is /uploads — now checks realPath + DIRECTORY_SEPARATOR prefix
- bulk_operation.php: $conn->close() was called before StatsModel($conn)
  construction; moved close() inside each branch to after all DB use
- upload_attachment.php: ticket ID validated as /^\d{9}$/ (exactly 9
  digits) breaking all tickets below ID 1,000,000,000 — changed to
  /^\d+$/ for any positive integer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:57:36 -04:00
jared 01f2dac2d6 Fix session_start guards, add missing API routes, rewrite README
- Added session_status() === PHP_SESSION_NONE guard to six API files
  (custom_fields, revoke_api_key, manage_templates, generate_api_key,
  get_template, manage_recurring) that called bare session_start() after
  RateLimitMiddleware had already started the session
- Registered /api/notifications.php and /api/user_avatar.php in index.php
  router (were missing, served only by direct file access)
- Complete README rewrite: remove all Discord references (Matrix/hookshot
  is the only external notification method), add hwmonDaemon API docs,
  document all TDS v1.2 features (kanban, charts, SLA, command palette,
  notification bell, watcher avatars, @mention, etc.), fix keyboard
  shortcuts table, add Matrix/LDAP env vars to setup section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:52:07 -04:00
jared 4433bad2ce Fix manage_workflows bind_param by-reference errors and duplicate session_start
- Extract expression args to local variables before bind_param (PHP 8 requirement)
- Guard session_start with session_status check in manage_workflows
- Remove redundant session_start from bulk_operation (RateLimitMiddleware starts it)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:27:02 -04:00
jared 1761f41943 Invalidate stats cache after any ticket-modifying API call
StatsModel::invalidateCache() was never called from any API, so the
60s cached stats persisted after bulk assign/status/priority changes,
ticket updates, assignments, and clones. Dashboard tiles showed stale
counts until the TTL expired.

Added invalidation to the four APIs that affect dashboard stat tiles:
- bulk_operation.php: after successful bulk assign/status/priority
- assign_ticket.php: after successful reassignment
- update_ticket.php: after any successful ticket update
- clone_ticket.php: after successful clone (open_tickets changes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:39:24 -04:00
jared 2378e56268 Fix bulk assign user search: replace broken combobox with typeahead
The combobox modal used lt-combobox-list but lt.combobox looks for
lt-combobox-dropdown — it returned immediately, wiring nothing.

Replaced with lt.typeahead which is correct for single-select search:
- Filters users client-side as you type (minChars:1, debounced 150ms)
- Shows display_name (username) with highlight on match
- onSelect stores user ID and shows "✓ Name" confirmation below input
- Input auto-focuses when modal opens
- Enter key now selects first result even without arrow-key navigation
  (same fix applied to lt.combobox Enter handler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:35:32 -04:00
jared 025963a78f Make title column greedy when other columns are hidden
Removes inline max-width/nowrap from title td, moves to CSS with
width:99% so the title column absorbs all available space freed by
hiding other columns. max-width:0 trick ensures overflow ellipsis
still works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:33:51 -04:00
jared c6037a9ccc Fix ticket age, bulk assign, add column visibility toggle
- TicketView: ticket age was measuring from last update not creation;
  fixed to always use created_at
- dashboard.js: bulk assign used non-existent onSelect callback (no
  selection was ever stored); fixed to onChange with selected[0],
  added max:1 to enforce single-select
- base.js: lt.combobox Enter key only fired when focusedIdx >= 0;
  now falls back to first filtered result when no arrow key used
- DashboardView + dashboard.js + dashboard.css: add COLS ▾ button on
  table header that opens a checkbox panel to show/hide optional
  columns (Ticket ID, Category, Type, Created By, Assigned To,
  Created, Updated); state persisted in localStorage, Reset button
  restores all; core columns (Priority, Title, Status, Actions) always
  visible; data-col attributes added to all th/td for CSS targeting

Notifications bell: was functional all along — was broken by the
notifications.php 500 error (now fixed). Avg resolution: correct,
tickets genuinely take ~158 days average on this dataset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:31:30 -04:00
jared 6c491c1baa Fix close-ticket UX, add cmd palette hint, breadcrumb, image lightbox
- ticket.js: status change requiring a comment now shows an inline
  modal with a textarea — comment is actually posted before the status
  changes, instead of just warning the user and changing anyway
- layout_header.php: add ⌘K button in header so users can discover
  the command palette; also removes inline onclick in favor of JS
  (CSP-safe via nonce script block already present)
- TicketView.php: upgrade breadcrumb to lt-breadcrumb markup with
  ticket title preview (truncated at 45 chars) and aria-current
- ticket.js + ticket.css: image attachments now render as clickable
  thumbnails (3rem×3rem) that open in lt.lightbox; non-image files
  keep the icon display unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:15:40 -04:00
jared 6eae9ef816 Add command palette (Ctrl+K / Cmd+K) globally
Adds lt-cmd-overlay HTML to layout_header.php and initializes
lt.cmdPalette with commands for: navigation (Dashboard, New Ticket),
filters (My Tickets, Unassigned, P1 Critical), admin pages (if admin),
and recent tickets (last 5 viewed, stored in localStorage).

TicketView.php records each viewed ticket ID to localStorage under
lt_recent_tickets so the command palette can surface them as Recent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:39:23 -04:00
jared bc88ba3612 Fix notifications 500 (audit_id column), smart resolution time units
- notifications.php: audit_log PK is audit_id not log_id; alias all
  three queries with audit_id AS log_id to fix 500 error
- DashboardView: avg resolution time now picks best unit automatically
  (min < 1h, hr < 48h, days < 14d, wks otherwise) with full hours
  shown in title tooltip; adds lt-stat-unit CSS for the suffix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:32:02 -04:00
jared 5e04478586 Fix parse error in notifications.php: escape inner quotes in LIKE string
The $statusSql double-quoted string contained '%"status":%' which caused
PHP to terminate the string at the inner double quotes, resulting in a
parse error (unexpected identifier 'status') on the beta server.

Also cleared stale stats cache that stored by_assignee in old name=>count
map format instead of the current array-of-objects format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:25:26 -04:00
jared 9494df2bf9 Add timezone and notif_last_seen to user_preferences valid keys whitelist
Both keys were silently dropped on batch save (the for-loop just
continued on unknown keys). timezone is sent by saveSettings() and
notif_last_seen is written by the notifications mark-read endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:01:38 -04:00
jared ac05b212b2 Fix performAdvancedSearch ReferenceError, settings save, sort reset, notifications 500, CSP
DashboardView.php: wrap performAdvancedSearch in a closure so it is
resolved at event-fire time rather than listener-registration time
(advanced-search.js loads later via pageScripts so the bare identifier
reference caused ReferenceError).

DashboardView.php: reset sort URL to page=1 so sorting all pages
instead of staying on the current page.

dashboard.js: add missing save-settings and close-settings cases to
the click delegation handler (were removed in a prior session under
the assumption they were in dashboard.js, but they were not).

notifications.php: replace JSON_EXTRACT-based comment join (not
universally supported) with a two-step PHP filter: fetch owner/watcher
ticket IDs first, then filter raw comment rows in PHP. Also fix the
status change LIKE pattern to match the actual logTicketUpdate format
{"status": {"from": ..., "to": ...}}.

SecurityHeadersMiddleware.php: add https://cdn.jsdelivr.net to
connect-src so Chart.js source maps load without CSP violations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:53:06 -04:00
jared df6c4de196 Fix notification comment query, status title, and is-hidden visibility
notifications.php: comment notifications never fired because the query
used action_type='comment'/entity_type='ticket' but logCommentCreate
logs action_type='create'/entity_type='comment'. Fix query to match
actual log format and extract ticket_id from details JSON.

notifications.php: status change notification titles always showed
"? → ?" because code read details.old_value/new_value but logTicketUpdate
stores the delta as {"status": {"from": ..., "to": ...}}.

base.css: move .is-hidden to base.css (global) — it was only defined in
ticket.css, so on the dashboard the ticket-preview popup had no hide
rule applied and was visible in the DOM at all times.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:47:39 -04:00
jared 2ccf4f2261 Clarify comment: @mention highlight skips markdown-rendered elements
markdown.js already calls renderMarkdownElements() on DOMContentLoaded
for all [data-markdown] elements; ticket.js only processes plain-text
comments to avoid double-rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:44:14 -04:00
jared dcbe6fb383 Fix double-firing event handlers, non-bubbling keyboard status event, and saved filter status type
- Remove duplicate edit-comment/delete-comment cases from TicketView.php inline
  script — ticket.js already handles them. Double-call of editComment() would
  immediately open then close the edit form (second call sees .editing → cancels)
- Fix keyboard shortcut 1-4 status change: dispatchEvent(new Event('change'))
  was non-bubbling (default), so the document-level change delegation in TicketView
  never received it. Now uses { bubbles: true } so updateTicketStatus() fires correctly
- Fix saved filter status type: getCurrentFilterCriteria() was saving status as a
  joined string "Open,Pending" but pill-click handler called .join() expecting an array
  (TypeError swallowed by try/catch → status filter silently not applied). Now saves
  as array; applySavedFilterCriteria handles both arrays and legacy strings
- Pill-click handler also updated to handle both array and string status formats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:40:16 -04:00
jared 914c33ecf3 Fix CSP-blocked chart scripts, undefined CSS classes, and double-firing click handlers
- Add nonce to charts and ticket-preview drawer inline <script> blocks in
  DashboardView.php (both were CSP-blocked — charts never rendered)
- Add .lt-modal-xs (280px) to base.css — used by quickStatus/quickAssign
  modals but was undefined, causing them to use full modal width
- Fix showConfirmModal in utils.js: class="text-center" → "lt-text-center"
  (undefined class); escape newlines as <br> so multi-line messages render
- Remove duplicate click-handler cases from DashboardView.php inline script
  that were already handled by dashboard.js, preventing double-firing
  (export-tickets, open-settings, remove-filter, etc. were all called twice)
- Fix manual-refresh action to use lt.autoRefresh.now() instead of bare
  window.location.reload() so modal/focus guards are respected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:00:35 -04:00
jared d588590989 fix: ticket preview popup wrong position and persists after interactions
- position:fixed popup was adding window.scrollX/scrollY to viewport coords
  from getBoundingClientRect(), making it appear far below link when scrolled
- Off-screen check compared against innerHeight + scrollY instead of innerHeight
- Added clamp to prevent negative coords (popup clipped off top/left edge)
- Hide preview on scroll, modal open, and pagination clicks (capture phase)
  so stale popup doesn't linger after user navigates away

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:51:39 -04:00
jared b7b6884bb0 fix: add missing CSS classes + clean up remaining inline styles
- Add .lt-modal-sm (max 360px) and .lt-modal-header--danger variant used
  in JS-generated bulk delete confirmation modal (no CSS = unstyled header)
- Add .lt-badge-sm for compact inline badges (comment counts, group tags)
- Add .lt-kv-row { display:contents } with .lt-kv-label/.lt-kv-value rules
  (was missing from previous commit — added in base.css)
- Replace style="text-align:center" with .lt-text-center in JS modal body
- Replace style="flex-direction:column" with .lt-flex-col on .lt-btn-group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:50:13 -04:00
jared 54887ffa24 fix: kanban not loading on refresh + modal horizontal scroll + lt-kv-row CSS
Kanban restore bug:
- set-view-mode click handler called populateKanbanCards() directly but never
  called setViewMode(), so ticketViewMode was never saved to localStorage
- DOMContentLoaded restore checked ticketViewMode (never written) — it should
  check lt_activeTab_<path> which lt.tabs.init() actually saves
- Fix: delegate to setViewMode() from the click handler; DOMContentLoaded
  reads lt_activeTab_<path> and calls populateKanbanCards() when tab-kanban

Settings modal horizontal scroll:
- .lt-modal-body was missing overflow-x: hidden; content wider than the modal
  (e.g. kbd elements with white-space: nowrap) caused horizontal scrollbar
- Added overflow-x: hidden + min-width: 0 to .lt-modal-body

Missing lt-kv-row / lt-kv-label / lt-kv-value CSS:
- These classes were used in TicketView, DashboardView, admin views but had
  no primary CSS rules (only a light-theme color override existed)
- Without rules, lt-kv-row divs were block-level grid children consuming one
  grid cell each, making lt-kv-label/value stack inside wrong columns
- Added display:contents on lt-kv-row so children participate directly in
  the lt-kv-grid 2-column grid; lt-kv-label/value get padding, border, and
  min-width:0 + overflow-wrap:break-word to prevent grid column blowout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:45:43 -04:00
jared 613886068d fix: sanitize FULLTEXT boolean mode search to prevent MySQL parse errors
User input containing MySQL boolean operators (+, -, (, ), ~, *, ", @)
was passed directly to MATCH...AGAINST in BOOLEAN MODE, causing MySQL to
parse them as search operators rather than literals. Input like '(test)'
or '-keyword' would result in a MySQL syntax error / empty results.

Strip boolean mode special chars before building the FULLTEXT term;
the raw search string is still used unchanged for the LIKE fallback parts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:40:25 -04:00
jared 847d6b2656 fix: malformed img tag in header avatar + notif footer inline styles
- Avatar img tag was missing closing > — the endif fired before the tag
  closed, causing the initials span to be parsed as an attribute value;
  this would silently break the avatar fallback when image fails to load
- Replace style="width:100%;text-align:center" on notif footer link with
  lt-w-full lt-text-center utility classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:38:39 -04:00
jared c2cd923d32 fix: RecurringTicketModel INSERT bind_param type string mismatch
next_run_at was typed 'i' (int) but stores a datetime string → should be 's'.
is_active was typed 's' (string) but stores 0/1 boolean → should be 'i'.
Positions 10-11 were swapped: 'ssssiiisssis' → 'ssssiiisssii'.
The UPDATE method already had the correct types; only INSERT was affected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:37:22 -04:00
jared 67a7d769f0 fix: unassigned filter not working + null guards on modal selects
- DashboardController: handle assigned_to='unassigned' before validateUserId()
  which discarded the string, causing the filter to never reach TicketModel;
  model already correctly converts 'unassigned' to IS NULL in SQL
- dashboard.js: add null guards before .value access on dynamically-created
  modal selects (bulkPriority, bulkStatus, quickStatusSelect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:35:04 -04:00
jared 84b104a501 fix: various inline style cleanup, a11y improvements, and bind_param bug
- Replace style="text-align:center" with .lt-text-center utility class in
  WorkflowDesignerView, CustomFieldsView, error_403, error_404, DashboardView JS string
- Replace style="margin-top:..." with .lt-mt-sm utility in WorkflowDesignerView
- Switch comment-edit-raw data-store textareas to .is-hidden class (TicketView PHP
  + JS-rendered; ticket.js template literal) — these are never shown, only read via .value
- Add aria-describedby="visibilityGroupsHint" + id on hint <p> in CreateTicketView
- Fix bind_param type string bug in manage_workflows.php PUT handler: 'ssiiiii' → 'ssiiii'
  (7 type chars for 6 params caused binding error on workflow transition updates)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:29:52 -04:00
jared ff109a710c fix: remove CSP-blocked inline event handlers (onerror, onclick)
- Remove all onerror="this.style.display='none'" from avatar imgs in
  layout_header.php, DashboardView.php, and TicketView.php (PHP + JS)
- Replace onclick SLA dismiss with data-action="dismiss-priority-banner"
  attribute; handler wired via existing click delegation in TicketView.php
- Global capture-phase error delegation in layout_footer.php handles all
  avatar image failures by adding .lt-avatar-img-err class (CSS display:none)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:15:45 -04:00
jared 1ab374531c fix: avatar image overlays initials, chart canvas responsive sizing
Avatar bug:
- base.css: .lt-avatar now position:relative; img is position:absolute inset:0
  so a loaded image covers the initials span (fixes img+initials shown together)
- base.css: .lt-avatar img.lt-avatar-img-err { display:none } — CSS hook for error state
- layout_footer.php: capture-phase error event delegation on .lt-avatar imgs
  replaces blocked inline onerror handlers (CSP has no unsafe-inline in script-src)

Chart bug:
- DashboardView: replaced display:flex section-body containers with a
  position:relative; width:100%; height:170px div wrapper for each canvas
  (Chart.js responsive:true reads parentNode dimensions; flex containers
  give canvas zero intrinsic width causing 0×0 render = empty charts)
- Removed has-lt-overlay from chart frames (no overlay div was injected)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:25:27 -04:00
jared bfe00ea0f6 fix: add lt-toggle--sm CSS variant for compact toggle switches in comment bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:46:31 -04:00
jared 04b019a8e1 feat: Chart.js donut/bar charts, Flatpickr dates, skeleton loaders, CSP update
- DashboardView: Charts row with 3 panels (priority donut, status donut, category bar)
  using Chart.js from CDN; data passed inline from PHP stats; TDS color palette
- DashboardView: Flatpickr date picker on advanced search date fields with TDS theme overrides
- dashboard.js: showTableSkeleton() shows lt-skeleton-row during filter-triggered reloads
  and auto-refresh; called before all location.reload() with delay
- dashboard.css: Flatpickr TDS theme overrides (dark BG, monospace font, TDS accent colors)
- SecurityHeadersMiddleware: Added cdn.jsdelivr.net to script-src and style-src CSP
  to allow Chart.js and Flatpickr from CDN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:45:02 -04:00
jared c15defc09b feat: duplicate detection + mark-as-duplicate, lt-toggle preferences in settings
- Dependencies tab: auto-loads potential duplicates via /api/check_duplicates.php
  on first activation; shows 'Mark duplicate' button per result which POSTs to
  ticket_dependencies with type=duplicates and refreshes the dependencies list
- Settings modal: replaced checkboxes with lt-toggle switches for
  notifications_enabled and sound_effects; loads current user prefs on modal open
  and saves via /api/user_preferences.php on SAVE button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:25:58 -04:00
jared 3c29c6ee6f feat: SLA live timer, notification bell, lt-toggle MD, right drawer, kanban drag-drop
- TicketView: SLA banner now shows live HH:MM:SS elapsed + countdown via JS setInterval
  (previously showed static hours from PHP)
- TicketView: Markdown toggles in comment form replaced with lt-toggle switches
- layout_header: In-app notification bell (🔔) with dropdown panel for all users
- layout_footer: Notification JS — polls /api/notifications.php every 60s, badge count,
  mark-all-read, panel open/close with Escape/outside-click
- api/notifications.php (new): Returns assign/comment/status-change events from audit_log
  for current user's tickets and watched tickets; mark-read via user_preferences
- DashboardView: Ticket preview right drawer — Ctrl+click title or ⊙ peek button
  opens lt-drawer-right with ticket summary extracted from table row DOM
- DashboardView: lt.sortable wired on all 4 kanban columns (group='kanban')
  Cross-column drag = status change via POST /api/update_ticket.php with optimistic UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:21:21 -04:00
jared 9916daa904 fix: TDS priority selector in ticket.js, asset versioning in admin views
- updateTicketField() now targets .lt-frame-ticket[data-priority] (TDS v1.2)
  instead of old .priority-indicator / .ticket-container selectors
- All 7 admin views: keyboard-shortcuts.js now uses dynamic ?v={$_v}
  instead of hardcoded unversioned path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:48:30 -04:00
jared 6727aeea29 feat: saved filter pills, mention autocomplete CSS, tooltips on dashboard table
- Dashboard: saved filter pills row above active filters bar — loads from API,
  click applies criteria as URL params, hidden when no saved filters exist
- ticket.css: add TDS-styled CSS for @mention autocomplete dropdown (was unstyled)
- Dashboard table: data-tooltip on Title and Assigned To columns for truncated text
  (lt.tooltip.init() auto-called by lt.init(), zero extra JS needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:06:46 -04:00
jared 0d8edc9d34 feat: trend dots on stat cards, team workload panel, stat model improvement
- Dashboard stat cards now show lt-dot trend indicators (up/warn/idle) based on
  created_today vs closed_today flow — no extra DB query needed
- Add collapsible Team Workload panel showing assignee open ticket counts with
  progress bars (green/cyan/red by load), avatar, and name
- StatsModel.getTicketsByAssignee() now returns proper objects with user_id,
  display_name, open_count (was name-keyed flat array); limit raised to 8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:04:41 -04:00
jared fca4896e0d fix: watcher avatars, dependency TDS styling, asset versions, nav dropdown light theme
- watch_ticket.php GET now returns watcher list (up to 6 users) for avatar group
- TicketView: watcher avatar group rendered next to WATCH button, refreshes on toggle
- Rewrite renderDependencies/renderDependents to use TDS lt-kv-grid/lt-badge/lt-btn classes
- renderDependencies: show lt-alert--warning blocker banner when blocked_by has open tickets
- Fix ALL hardcoded ?v=20260327 asset version strings in CreateTicketView + all admin views
- base.css: fix .lt-nav-dropdown-menu hardcoded background → var(--bg-overlay)
- base.css: add light-theme overrides for nav dropdown menu (background, links, hover)
- ticket.css: add .lt-avatar-group and .lt-avatar--overflow styles for watcher display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:02:30 -04:00
jared c0dfbdbc26 feat: status dots, priority banners, lt-tags, command palette, activity timeline improvements
- Fix DashboardView asset version (was hardcoded 20260327, now uses config ASSET_VERSION)
- Add lt-dot status indicators on dashboard table rows and ticket view toolbar
- Add lt-tag display for Category/Type in ticket read mode (swaps to select in edit mode)
- Add P1/P2 SLA alert banner with elapsed time, progress bar, per-session dismiss
- Wire command palette (Ctrl+K): global nav + admin links via lt.cmdPalette.init()
- Fix cmdPalette.init() call format (flat array, not nested group objects)
- Improve activity timeline: richer formatAction(), better color coding by event type,
  inline status transitions shown in meta row, icon column added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:54:26 -04:00
jared 85afec64ac Add responsive .lt-main.lt-container overrides to match production base.css
Production base.css has per-breakpoint .lt-main.lt-container rules that
explicitly set padding-top with tighter spacing at SM/XS viewports. Adding
these to beta to match — ensures header clearance is bulletproof at all sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:13:16 -04:00
jared ec92445a0f Force header clearance via inline style on main element
CSS cascade fixes were correct but browser was serving cached base.css.
Inline style cannot be cached separately and bypasses all cascade issues.
CSS variables still respect media query :root overrides so --header-height
resolves to the correct value (50px SM, 46px XS) at each breakpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:52:22 -04:00
jared 0eab5d40e6 Restore .lt-main.lt-container combined selector — proper cascade fix
The TDS v1.2 sync removed the .lt-main.lt-container combined selector that
was already in the project's base.css. That selector has specificity (0,2,0)
vs single-class (0,1,0), so it always wins over .lt-container padding
shorthand at every breakpoint without needing per-breakpoint overrides.

Also restored flex:1, width:100%, min-width:0 on .lt-main that were dropped.
Removed the incorrect per-breakpoint .lt-main and #main-content hacks added
today which were the wrong approach to the same problem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:49:00 -04:00
jared 3cfe46050b Fix header overlap with ID selector — unambiguous highest specificity
Use #main-content (specificity 1,0,0,0) to set padding-top at each breakpoint.
This cannot be overridden by any class-based rule regardless of cascade order,
permanently fixing the fixed header overlapping page content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:45:14 -04:00
jared e71f35c041 Fix asset cache-busting — include base.css and base.js in ASSET_VERSION
Previously only dashboard/ticket assets were tracked, so changes to base.css
and base.js were never reflected in the cache-busting version string. Browsers
served stale cached copies, meaning the header padding-top fix never reached
users. Touch base files to bump mtime and force a cache miss immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:43:18 -04:00
jared 6102985f92 Fix header overlap at all breakpoints — restore lt-main padding-top
Every media query that overrides .lt-container { padding } with a shorthand
was clobbering .lt-main { padding-top } because both selectors have equal
specificity and the container rule came later in the file. Added .lt-main
padding-top restores after each affected breakpoint (LG 1024-1279px, MD
768-1023px, 1920px+). The laptop range (LG) was the likely culprit on desktop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:41:33 -04:00
jared e91709798b Fix header overlapping content at mobile breakpoints
In the SM (≤767px) and XS (≤479px) media queries, .lt-container { padding }
shorthand appeared after .lt-main { padding-top } with equal specificity,
causing the shorthand to clobber the header-clearance padding-top. Swap order
so .lt-main always wins.

Also remove redundant lt-scanlines div — body::before in base.css already
renders the scanline overlay globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:19:22 -04:00
jared 4150e1ced3 Fix lt-scanlines header overlap — move class off body to dedicated div
body::before and body::after are used for background grid/gradient effects.
Adding lt-scanlines to body caused ::after conflict (higher specificity) and
put the scanline overlay at z-index 9998, above the header at z-index 300.

Move lt-scanlines to a dedicated fixed div so pseudo-elements don't conflict
and the header remains fully visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:12:07 -04:00
jared cfdc9e0f37 Sync TDS v1.2 additions: scanlines, cursor, radar, display-field, VT323
- Sync base.css + base.js from web_template (adds lt-scanlines,
  lt-cursor, lt-radar, lt-display-field, --font-crt/VT323 token)
- Add VT323 to Google Fonts link in layout_header.php
- Add lt-scanlines to <body> — CRT scanline overlay, light-mode suppressed
- Replace custom .editable-metadata:disabled CSS override in ticket.css
  with the canonical .lt-display-field class from base.css
- Switch Priority/Category/Type/Visibility selects and visibility-group
  checkboxes in TicketView.php from disabled attribute to lt-display-field
- Update toggleEditMode() in ticket.js to add/remove lt-display-field
  instead of toggling the disabled attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:55:12 -04:00
jared 55c6fc81db Fix duplicate users in bulk/quick assign modals; add combobox search
Root cause: DashboardView.php and dashboard.js both had a global
document.addEventListener('click') handler handling the same bulk-assign
and quick-assign actions. Every click fired both handlers, creating two
modals and two API fetches that both appended to the same select element.

Fix: Remove duplicate cases (bulk-*, navigate, view-ticket, quick-*,
set-view-mode, toggle-*, clear-selection) from DashboardView.php's inline
handler. dashboard.js already handles all of these correctly.

Also replace <select> with lt.combobox in both bulk-assign and
quick-assign modals so large user lists are searchable instead of a
long scrolling dropdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:13:10 -04:00
jared fdc6d3d463 Fix ASCII art alignment, readonly input opacity, api key visibility
Use white-space:pre-wrap on description view div so newlines and multiple
spaces are preserved natively — no <br> replacement, ASCII art aligns
correctly since body is already monospace (JetBrains Mono).

Override opacity:1 on readonly API key input so generated keys are fully
readable instead of being faded to 0.45 by base.css [readonly] rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:43:18 -04:00
jared 72d5061867 Fix description line breaks and disabled-field readability
Ticket descriptions are plain text — renderDescriptionView() now always
uses nl2br instead of parseMarkdown(), preventing markdown from mangling
single newlines into run-on paragraphs.

Override base.css opacity:0.45 on disabled .editable-metadata selects
(Priority, Category, Type) so they remain legible at full contrast on
dark/OLED screens in read mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:36:10 -04:00
jared 1d721eecb4 fix: description unreadable in dark mode / OLED — swap disabled textarea for lt-markdown div
Root cause: disabled textarea gets opacity:0.45 + color:var(--text-muted) from
base.css, making it near-invisible on OLED (true-black background).

Fix:
- TicketView: add #ticketDescriptionView (div.lt-markdown) alongside the textarea;
  textarea is now hidden by default (style="display:none"), view div is shown
- ticket.js: renderDescriptionView() renders raw text via parseMarkdown() or nl2br;
  showDescriptionView() / showDescriptionEdit() swap between them;
  toggleEditMode() calls showDescriptionEdit() when entering edit, and
  renderDescriptionView() + showDescriptionView() when returning to read mode
- ticket.css: .ticket-description-view sets full-contrast text-primary/secondary
  colors, min-height, and line-height for comfortable reading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:10:39 -04:00
jared cfb88d9c88 fix: CSRF token staleness causing intermittent 403 on POST actions
Root cause: bootstrap.php rotates the CSRF token on every successful POST,
but most API endpoints called echo json_encode() directly instead of
apiRespond() — so the rotated token was never returned to the client.
The next POST from the same page sent the now-invalid old token → 403.
Refreshing the page loaded a fresh token, making it work once.

Fixes:
- assign_ticket.php, watch_ticket.php: switch to apiRespond()
- saved_filters.php, user_preferences.php: replace all echo json_encode
  calls with apiRespond() (19 and 12 call sites respectively)
- base.js: both apiFetch() and _apiFetchAuth() now update window.CSRF_TOKEN
  whenever a response includes a csrf_token field, keeping the client
  permanently in sync with server-side rotations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:01:18 -04:00
jared a89095cbcc chore: delete applied migrations (001-002, 004-005 all applied to DB)
All SQL migration files have been applied and recorded in the migrations
tracking table. Folder intentionally empty — migrate.php kept as runner
for future one-time schema changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:03:16 -04:00
jared ade1a70214 feat: ticket watchers, fulltext search, single-query pagination, watcher notifications
Ticket watchers:
- api/watch_ticket.php: GET (watch state) + POST (watch/unwatch toggle)
- index.php: route for /api/watch_ticket.php
- TicketView: WATCH/UNWATCH button with live state fetch and toggle
- NotificationHelper::notifyWatchers(): fetches watchers from DB, resolves
  Matrix IDs via Synapse, fires notification to watchers + global list
- add_comment.php, update_ticket.php: call notifyWatchers on comment and
  status-change events respectively

Fulltext search:
- TicketModel::hasFulltextIndex(): detects FULLTEXT index via information_schema
- getAllTickets(): uses MATCH...AGAINST when fulltext index exists, LIKE fallback
  when not yet applied — zero-downtime rollout

Single-query pagination:
- getAllTickets() replaces separate COUNT + SELECT with COUNT(*) OVER() window
  function — one round trip to DB per page load instead of two

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:00:32 -04:00
jared 0acf5e84c3 feat: duplicate link action, watcher migration, fulltext search migration
- CreateTicketView: "Link as duplicate" button on each duplicate result;
  stores chosen ticket ID in hidden field, auto-creates duplicates dependency
  after ticket is saved (TicketController)
- migrations/004: ticket_watchers table (ticket_id, user_id primary key)
- migrations/005: FULLTEXT index on tickets(title, description) for fast
  relevance search replacing LIKE scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:54:00 -04:00
jared c8181e8076 feat: comment pagination, Matrix integration, Synapse mention resolution
Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
  with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
  passes totalComments/commentOffset/isAdmin to window.ticketData

Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
  sendMentionNotification(), sendAssignmentNotification() alongside existing
  sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
  Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
  MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
  mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
  when MATRIX_NOTIFY_ASSIGNMENTS=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:34:16 -04:00
jared cc3f667d4c Wire optimistic locking, visibility audit log, full ticket export
Optimistic locking:
- TicketView now includes updated_at in window.ticketData
- ticket.js saveTicket() sends expected_updated_at on every save so
  the server can detect concurrent edits
- On conflict response, shows a clear toast: "ticket was modified by
  someone else while you were editing — reload to see latest version"
- On success, syncs window.ticketData.updated_at from server response
  so subsequent saves use the correct lock key
- update_ticket.php now returns updated_at in success response

Visibility audit log:
- updateVisibility() result is now checked; on success, logs a delta
  entry to the audit trail with from/to visibility and groups so the
  timeline shows who changed visibility and when

Full ticket export:
- export_tickets.php now accepts format=full with a single ticket_id
- Produces a JSON file containing ticket fields, flat comment list
  (with author, timestamps, text), and the full audit timeline
- Access-controlled: respects canUserAccessTicket() before exporting
- EXPORT button added to ticket toolbar linking directly to the endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:19:01 -04:00
jared 2fdd42b45b UX and architecture fixes: bulk-delete, template guard, statuses config
Bug fixes:
- bulk-delete action called undefined bulkDelete() — wired to the
  existing showBulkDeleteModal() so the confirmation modal actually shows

UX:
- Template loader now checks for existing title/description and asks
  for confirmation before overwriting user-typed content
- Visibility select shows a dynamic hint paragraph that updates when
  the user changes the selection (public/internal/confidential)

Architecture:
- TICKET_STATUSES added to config as single source of truth; all
  hardcoded ['Open','Pending','In Progress','Closed'] arrays in
  DashboardView now read from config; bulk-status modal in dashboard.js
  reads window.TICKET_STATUSES (set from PHP) with array fallback
- ASSET_VERSION now auto-computed from max mtime of dashboard/ticket
  CSS+JS files so browsers always pick up changes on deploy; manual
  override still available via ASSET_VERSION in .env
- Removed 10 dead standalone stat methods from StatsModel (getOpenTicketCount,
  getClosedTicketCount, getTicketsByPriority, etc.) — all superseded by
  the consolidated fetchAllStats() queries, never called externally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:09:29 -04:00
jared 277daf6f00 Remove dead TicketController::update() method
No route in index.php ever invokes this method — all ticket updates
go through api/update_ticket.php. The method also lacked authorization
checks, making its removal strictly safer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:32:55 -04:00
jared f709e98bd3 Security: add authorization checks to ticket_dependencies API
- POST /ticket_dependencies: verify user can access both the source
  ticket and the target ticket before creating a dependency
- DELETE by ticket IDs: verify user can access source ticket; also
  validate dependency_type against the allowed whitelist
- DELETE by dependency_id: look up dependency's ticket before deletion
  and verify user can access it, preventing IDOR
- custom_fields.php: validate json_decode returns an array on POST/PUT;
  add http_response_code(400) to all error responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:26:01 -04:00
jared e6b6a2a88c Security/correctness: visibility filtering, Content-Type headers, group validation
- TicketModel::getAllTickets() now accepts optional $user param and applies
  getVisibilityFilter() so non-admin users cannot see internal/confidential
  tickets they lack access to from the dashboard listing
- DashboardController passes $GLOBALS['currentUser'] to getAllTickets()
- clone_ticket.php: move Content-Type header to top so all error paths send
  correct JSON content type
- AuthMiddleware: filter group names from HTTP header to [a-z0-9_-] only,
  preventing header injection via malformed group names
- add_comment.php: return HTTP 201 on success, 500 in catch block
- update_comment.php, delete_comment.php: return 500 in catch blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:23:16 -04:00
jared f983269f93 Fix file upload security, bind_param mismatch, and cookie flags
- upload_attachment.php: derive stored file extension from validated MIME type
  instead of user-supplied filename, preventing executable extension attacks
  (e.g. a PHP file renamed to evil.txt would now be stored as .txt)
- CustomFieldModel.php: fix bind_param type string in updateDefinition()
  'sssssiiiii' (10 chars) → 'sssssiiii' (9 chars) to match 9 SQL placeholders
- RateLimitMiddleware.php: replace MD5 with SHA256 for rate limit file hashing
- user_preferences.php: add httponly, secure, samesite=Lax flags to ticketsPerPage
  cookie to prevent XSS/CSRF cookie theft

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:14:18 -04:00
jared 7be283423a Fix loose comparisons, missing response codes, and session handling
- ticket.js: escape dependency_id with lt.escHtml() in data attribute
- assign_ticket.php: strict (int) cast for ticket_id (> 0 check), authorization
  comparisons, and add missing http_response_code(400) on invalid user ID
- TicketView.php: strict (int) cast for priority select, assigned_to select,
  and comment ownership check
- CommentModel.php: strict (int) cast for parent_comment_id thread comparison
- UserModel.php: strict (int) cast for is_admin check
- export_tickets.php: conditional session_start() to avoid double-start warning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:39:46 -04:00
jared 2e450dc01d 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>
2026-03-29 17:02:40 -04:00
jared f0abadfc57 Fix 500 error for non-admin users on dashboard
StatsModel queries used `FROM tickets WHERE` with no table alias, but
getVisibilityFilter() returns SQL referencing `t.visibility`. Admins
were unaffected because they get `1=1` with no column references.
Added `t` alias to all three tickets queries that use $visSQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:42:39 -04:00
jared d33f761a55 Fix loose comparisons in authorization checks
- TicketModel.php: fix bind_param "sssi"→"issi" for ticketId in addComment()
- TicketModel.php: use strict (int) cast === for confidential ticket access check
- update_ticket.php: use strict (int) cast !== for creator/assignee auth check
- AttachmentModel.php: use strict (int) cast === for upload ownership check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:35:48 -04:00
jared cfbef029cb Fix bind_param type mismatches and integer validation
- TemplateModel.php: fix bind_param "ssssiii" -> "sssssii" (5 strings not 4)
- manage_workflows.php: fix bind_param 'ssiiii' -> 'ssiiiii' (4 int columns)
- download_attachment.php, delete_attachment.php, get_template.php: replace is_numeric()
  with strict int cast+equality check to reject floats and scientific notation
- manage_recurring.php: validate JSON input before accessing schedule_type key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:33:48 -04:00
jared 5242d42fa7 Fix type safety and TDS class naming issues
- bulk_operation.php: replace is_numeric() with strict int cast+equality to reject scientific notation
- AttachmentModel.php: fix bind_param type strings (s→i for integer ticket IDs)
- CommentModel.php: use strict !== comparison with (int) cast for user_id ownership checks
- ticket.js: replace all non-TDS class names (text-amber→lt-text-amber, btn→lt-btn variants, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:29:28 -04:00
jared d8e6dcf7fa fix: CSS nesting conflict, dashboard.js dead code removal, admin view escaping
CSS:
- ticket.css: use combined .comment.thread-depth-N selectors to resolve the
  margin-left conflict between .comment-reply and .thread-depth-N classes

dashboard.js:
- Remove legacy initStatusFilter() (superseded by TDS v1.2 sidebar filters)
- Remove initTableSorting() call (client-side sort conflicts with server ?sort=)
- Remove quickSave() + saveTicket() (old hamburger-menu ticket page functions)
- Remove global loadTemplate() (duplicate of IIFE-scoped version in CreateTicketView)
- Remove generateSkeletonRows/Comments/Stats helpers (never called, used
  unregistered CSS class names like .skeleton-row-tr)
- Remove "force dark mode" lines that overrode the user theme preference
- Fix non-TDS CSS classes in modal templates: text-center → style, text-green →
  lt-text-cyan, mb-half → lt-mb-xs, modal-warning-text → lt-text-danger

Admin views:
- RecurringTicketsView: replace innerHTML += loop with createElement/appendChild
  (avoids serial DOM re-parsing on each iteration)
- AuditLogView: add htmlspecialchars() to action_type option values (consistency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:34:34 -04:00
jared 6b76496640 fix: CSRF on ticket create form, DOM-safe duplicate list, audit-log param validation
- TicketController::create: validate csrf_token from POST before processing
- CreateTicketView: emit hidden csrf_token field; replace innerHTML duplicate
  list with DOM methods to prevent any XSS path; guard checkDuplicates() with
  lt.api availability check
- index.php audit-log: allowlist action_type; validate date_from/date_to as
  YYYY-MM-DD before passing to query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:26:52 -04:00
jared b40c404828 fix: ldap_get_entries returns raw binary, remove incorrect base64 decode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:53:49 -04:00
jared 18bf1fde0e feat: LDAP avatar support via lldap
- Create tinker-tickets service account in lldap (lldap_strict_readonly)
- Add /api/user_avatar.php: binds to lldap, fetches avatar attribute,
  caches JPEG to uploads/avatars/, returns 404 sentinel for missing photos
- Install php8.2-ldap on LXC 132 (beta) and LXC coding server
- Update layout_header.php: show lt-avatar with photo overlay + initials fallback
- Update TicketView.php: comment avatars use photo overlay pattern
- Add .lt-avatar-img / .lt-avatar-initials CSS for photo-over-initials layout
- Add LDAP_* config keys to config.php and .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:47:08 -04:00
jared 87f878ee6b Fix XSS: escape user_name and created_at in reply DOM injection
submitReply() built a replyDiv.innerHTML template literal using
data.user_name (API response) without escaping — an attacker-controlled
display name could inject arbitrary HTML. Fix: wrap all API-sourced
string values in lt.escHtml() within the template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:42:27 -04:00
jared 82aa4bf5de Harden attachment deletion and template CRUD validation
- delete_attachment.php: add realpath() path traversal check before
  unlink() — mirrors the defense-in-depth already in download_attachment.php;
  also cast ticket_id to int when building the path
- manage_templates.php: add input validation to POST and PUT handlers:
  required field checks, max length caps (name 100, title 255, desc 64KB),
  allowlist validation for category/type, priority clamped to 1-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:41:22 -04:00
jared e2c23d0405 Fix XSS: escape userName in reply form insertAdjacentHTML template
showReplyForm() read userName from data-user attribute (decoded by
the browser from HTML entities) and injected it unsanitized into
insertAdjacentHTML() — any HTML special chars would be parsed as markup.
Fix: wrap with lt.escHtml() before interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:38:30 -04:00
jared 170bd86aa6 Show only changed fields (delta) in ticket activity timeline
Before: entire ticket data was logged and shown in the activity tab.
After: compare old vs new values before saving; log only fields that
actually changed as { field: { from: '...', to: '...' } } pairs.

- TicketController.php: fetch old ticket before update, compute delta
- api/update_ticket.php: same fix for the API endpoint (currentTicket
  already fetched for auth, reuse it for delta comparison)
- TicketView.php: render delta format as "Field: old → new" with color;
  truncate long values (description) at 60 chars; keep legacy flat format
  as fallback for older log entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:35:01 -04:00
jared 3bb4792635 Fix header overlap, is-hidden missing globally, and CreateTicketView CSS
- base.css: add .lt-main.lt-container combined selector (specificity 0,2,0)
  to prevent responsive .lt-container padding shorthand from overriding
  the fixed-header clearance padding-top — affected all viewports < 1280px
- base.css: add .is-hidden { display: none !important } globally; it was
  only defined in ticket.css so dashboard ticketPreview popup rendered
  as a green box at 0,0 on page load instead of being hidden
- CreateTicketView.php: add dashboard.css to pageStyles so create-ticket-
  meta-grid, lt-form-hint, visibility-groups-list, duplicate-list classes
  are available (they were undefined when only ticket.css was loaded)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:30:00 -04:00
jared b42597c927 Fix CSS variables, missing utility classes, API hardening, and audit log UX
- base.css: add --lt-border/--lt-surface aliases so dashboard.css respects
  theme instead of using hardcoded fallback colors
- base.css: add lt-select-sm/lt-input-sm compact size variants (used in 15+
  places), lt-msg-danger alias for lt-msg-error, lt-form-hint--warn,
  lt-font-mono utility class
- audit_log.php: cap ?limit= at 500 to prevent DoS via oversized queries
- ApiKeysView.php: replace deprecated execCommand('copy') with lt.copy();
  add integer casts on api_key_id in id attr and data-id
- AuditLogView.php: rebuild pagination with windowed prev/next/ellipsis
  pattern matching DashboardView; integer cast on user_id select option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:22:12 -04:00
jared e721b33911 Align UI with web_template TDS v1.2 standards
- Replace lt-chip priority badges with lt-badge lt-badge-p[1-4] across
  DashboardView, TemplatesView (matches web_template sticky table pattern)
- Add lt-theme-btn theme toggle to header-right; wire lt.theme.toggle()
- Replace ASCII art empty state with lt-empty-state component in dashboard
- Standardize tab wrapper lt-tabs → lt-tab-bar in Dashboard and TicketView
- Add missing lt-keys-help modal to layout_footer (fixes ? key doing nothing)
- Add lt-cmd-overlay command palette container + lt.cmdPalette.init() nav
- Add .lt-timeline-action CSS rule (used in TicketView, was undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:06:40 -04:00
jared d7775e62ec Fix layout regressions, nav drawer structure, and security issues
- base.css: add width:100%+min-width:0 to .lt-main so flex column body
  doesn't shrink content due to margin:0 auto from .lt-container
- layout_header.php: restructure mobile nav drawer to match web_template
  exactly (nav-drawer-links nav, direct <a> links, section div, no ul/li
  wrapper, overlay after drawer); fix lt-nav-overlay id mismatch with
  base.js; rename lt-header-username -> lt-header-user (matches CSS);
  add JSON_HEX_TAG to all inline json_encode calls (closes </script> XSS)
- base.css: add lt-kv-row/label/value aliases (display:contents pattern
  used in web_template v1.2 kv-grid); add lt-badge-sm variant
- Admin views: add missing .catch() on editField/editRecurring/loadUsers;
  add JSON_HEX_TAG to json_encode in TemplatesView/WorkflowDesignerView
- TicketView: add JSON_HEX_TAG to all ticket-data json_encode calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:43:24 -04:00
jared 51f6991f9d feat: nano-style footer bar, missing utility classes, CSS semantic vars
- layout_footer.php: add lt-footer with context-sensitive keyboard hint bar
  ([ ~ ] HOME | [ / ] SEARCH | [ + ] NEW | [ * ] CFG | [ ? ] HELP)
  Context adapts for dashboard, ticket, and admin pages
- layout_footer.php: wire show-keyboard-help and open-settings for all pages
- base.css: body { display:flex; flex-direction:column } + lt-main { flex:1 }
  so footer sticks to bottom of viewport on short pages
- base.css: add lt-flex-gap-xs/sm/md/lg and lt-flex-align-start/center/end
  (were used across all views but never defined — causing broken layouts)
- base.css: add --lt-danger/amber/cyan/success/text-primary CSS variables
  (referenced in ticket.css and dashboard.css fallbacks but never declared)
- base.css: add lt-text-danger/warning/success/info/primary utility classes
  (used in TicketView, DashboardView, admin views but not defined in base.css)
- DashboardView.php: remove ascii-banner.js (loaded but never called)
- TemplatesView.php: fix priority badge from lt-p* to lt-chip component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:16:05 -04:00
jared 9bdeaf7731 fix: deep audit — wire TDS v1.2 components, fix kanban/tabs/bulk/avatar
- ticket.js: fix showTab() early return preventing attachments/deps from loading
- ticket.js: fix performStatusChange() overwriting lt-status-* classes
- dashboard.js: fix updateSelectionCount() using is-visible instead of style.display
- dashboard.js: fix populateKanbanCards() to use #kanban-col-* IDs (TDS v1.2)
- dashboard.js: fix setViewMode() removing references to old non-TDS elements
- dashboard.js: remove mobile-bottom-nav injection (no CSS existed for it)
- dashboard.css: add full lt-kanban-card component styles with priority accents
- dashboard.css: add mobile sidebar overlay, filter toggle, ticket preview popup CSS
- DashboardView.php: replace priority badges with lt-chip component
- TicketView.php: add lt-avatar with initials to comment author display
- ApiKeysView.php: enhance API usage section with lt-code-block component + curl example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:58:14 -04:00
jared 79c2d2b513 feat: complete TDS v1.2 redesign across all views
Full application redesign using Terminal Design System v1.2 (lt-* class
system). Introduces shared layout_header/footer partials, upgrades
base.css/base.js to TDS v1.2, and rewrites all views (Dashboard, Ticket,
CreateTicket, and all 7 admin views) with lt-frame, lt-table, lt-modal,
lt-stats-grid, lt-kv-grid, and data-action event delegation patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:05:42 -04:00
jared 1989bcb8c8 Migrate status and priority display to lt-status/lt-priority design system classes
DashboardView.php:
- Table status column: replace status-{slug} with lt-status lt-status-{slug} for consistent [● Status] bracket decoration from base.css
- Table priority column: replace raw number with lt-priority lt-p{N} empty span for [▲▲ P1 CRITICAL] style badges

dashboard.js:
- Kanban card priority badge: replace card-priority p{N} with lt-priority lt-p{N} to use the design system badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:25:49 -04:00
jared 0a2214bfaf Improve web_template compliance: lt.bytes.format, lt.tableNav, lt.statsFilter
- ticket.js: replace custom formatFileSize() with lt.bytes.format() from web_template base.js; remove the now-redundant local function
- DashboardView.php: add id="tickets-table" and wire lt.tableNav.init() for j/k/Enter keyboard row navigation
- DashboardView.php: add lt-stat-card class + data-filter-key/data-filter-val to open/critical/closed stat cards; wire lt.statsFilter.init() + window.lt_onStatFilter so clicking a stat card filters the ticket list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:07:49 -04:00
jared e7d01ef576 Return 404 (not 403) for inaccessible tickets in TicketController
Returning 403 Forbidden leaks the existence of tickets to users who
should not know about them. Use 404 Not Found consistently across all
access-controlled endpoints to prevent enumeration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:28 -04:00
jared a403e49537 Use canUserAccessTicket() in clone_ticket.php; fix README bootstrap entry
- clone_ticket.php: replace custom visibility check with centralized canUserAccessTicket(); return 404 (not 403) for inaccessible tickets
- README.md: remove bootstrap.php from the API endpoints table (it's a shared include, not a public endpoint); correct its project structure description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:03 -04:00
jared 06b7a8f59b Consolidate showConfirmModal into utils.js, remove duplicate from dashboard.js
utils.js is loaded on all pages (dashboard, ticket, admin views) before dashboard.js.
Moving the canonical definition there and removing the guard + the copy in dashboard.js
eliminates the redundant redefinition on every page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:46 -04:00
jared 9f1a375e5a Apply visibility filtering to dashboard statistics
StatsModel.getAllStats() now accepts a user array and applies the same
getVisibilityFilter() logic used by ticket listings. Admins continue to
share a single cached result; non-admin users get per-user cache entries
so confidential ticket counts are not leaked in dashboard stats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:01 -04:00
jared 84cc023bc4 Enforce ticket visibility on attachment and update endpoints
- delete_attachment.php: check canUserAccessTicket() before allowing deletion; return 404 (not 403) for inaccessible tickets to prevent existence leakage
- upload_attachment.php: verify ticket access on both GET (list) and POST (upload) before processing
- update_ticket.php: pass currentUser to controller; add canUserAccessTicket() check before permission check; return 404 for inaccessible tickets instead of leaking existence via 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:42:47 -04:00
jared 164c2d231a Fix visibility enforcement and register missing API routes
Security fixes:
- add_comment.php: verify canUserAccessTicket() before allowing comment creation
- assign_ticket.php: use canUserAccessTicket() to prevent info leakage via 403 vs 404
- check_duplicates.php: apply getVisibilityFilter() so confidential ticket titles are not exposed in duplicate search results
- ticket_dependencies.php: verify ticket access on GET before returning dependency data

Route registration:
- Register 7 previously missing API endpoints in index.php: custom_fields, saved_filters, audit_log, user_preferences, download_attachment, clone_ticket, health

Frontend:
- ticket.js: fill empty catch block and empty else block in addComment() with proper error toasts

Documentation:
- README.md: document all API endpoints and update project structure listing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:39:02 -04:00
jared ce95e555d5 CSS class migrations: admin views and boot overlay fade-out
- Replace style.display with .is-hidden classList in ApiKeysView, CustomFieldsView, RecurringTicketsView
- Convert boot overlay fade-out from style.opacity to .boot-overlay--fade-out CSS class
- Add .boot-overlay--fade-out rule to dashboard.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:20:55 -04:00
jared f45ec9b0f7 CSS class migrations in CreateTicketView: duplicate warning, visibility groups
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:18:16 -04:00
jared 5a41ebf180 Convert ticket preview popup visibility to use .is-hidden CSS class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:16:49 -04:00
jared e35401d54e CSS class migrations for ticket page: tabs, visibility, markdown preview, uploads
- Switch tab show/hide from style.display to .tab-content.active CSS class
- Convert visibilityGroupsField, markdownPreview, uploadProgress to use .is-hidden class
- Replace comment text div style.display with classList.add/remove('is-hidden')
- Add .is-hidden utility class to ticket.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:13:55 -04:00
jared 913e294f9d CSS class migrations: stat-card cursor, view toggle, bulk actions visibility
- Replace stat-card cursor:pointer inline style with CSS rule
- Convert view toggle (table/card) to use .is-hidden CSS class
- Convert bulk-actions and export-dropdown to use .is-visible class
- Add .is-hidden/.is-visible utility rules to dashboard.css
- Remove duplicate lt.keys.initDefaults() call from dashboard.js
- Remove redundant setTimeout from view mode restore
- Add lt.keys.initDefaults() to dashboard.js (was missing entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:08:28 -04:00
jared 28aa9e33ea Fix XSS: escape table data and sanitize sort/pagination URL params
- htmlspecialchars() on category, type, status in table rows
- htmlspecialchars() on data-status attributes in quick-action buttons
- Restrict $currentDir to 'asc'|'desc' to prevent class injection
- htmlspecialchars() on all http_build_query URLs in pagination and sort headers
- htmlspecialchars() on AuditLogView pagination URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:40:51 -04:00
jared 31aa7d1b81 Fix JS SyntaxError breaking tabs, textarea scrolling, and XSS escaping
Bug fixes:
- ticket.js: Remove duplicate const textarea declaration inside showMentionSuggestions()
  (was redeclaring a parameter, causing SyntaxError that broke all tab switching)
- ticket.css: Add overflow:hidden + resize:none to disabled textarea so description
  shows full height without internal scrollbar (page scrolls instead)
- ticket.js: Trigger height recalculation when entering edit mode on description

XSS/escaping fixes:
- TicketView.php: htmlspecialchars() on description textarea content (closes </textarea> injection risk)
- TicketView.php: htmlspecialchars() on ticket status and workflow transition status strings
- DashboardView.php: htmlspecialchars() on $cat/$type in input value= attributes
- RecurringTicketsView.php: htmlspecialchars() on composed schedule string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:34:55 -04:00
jared 7695c6134c Accessibility pass: ARIA roles, label associations, CSS class migrations
- Add role=dialog/aria-modal/aria-labelledby to all 12 modal overlays (JS + PHP)
- Add aria-label="Close" to all 14 modal close buttons
- Add full ARIA combobox pattern to @mention autocomplete (listbox, option, aria-selected, aria-expanded)
- Add for= attributes to admin filter form labels (AuditLog, UserActivity, ApiKeys)
- Remove dead closeOnAdvancedSearchBackdropClick() from advanced-search.js

CSS/JS style cleanup:
- Move .ascii-banner static styles from JS inline to CSS class; add .ascii-banner--glow
- Add .ascii-banner-cursor, .loading-overlay--hiding, .has-overlay, tr[data-clickable]
- Add .animate-fadein/.animate-fadeout/.comment--deleting to ticket.css
- Add .lt-toast--hiding to base.css; remove opacity/transition inline JS
- Remove redundant cursor:pointer JS (already in th{} CSS rule)
- Remove trailing space in lt-select class attributes

Bug fixes:
- base.js: boot overlay opacity inline style was overriding .fade-out class opacity via
  specificity (1000 vs 20), preventing the fade-out animation — removed
- ascii-banner.js: cursor used blink-caret (border-color only) instead of blink-cursor
  (opacity-based), so the █ cursor never actually blinked — fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:29:58 -04:00
jared 11f75fd823 Migrate all raw fetch() calls to lt.api, fix CSS fallback values
- Replace all 23 raw fetch() calls in dashboard.js and ticket.js with
  lt.api.get/post/delete — removes manual CSRF header injection,
  manual JSON parsing boilerplate, and response.ok checks throughout
- dashboard.js: 10 calls (inline save x2, template GET, 5x bulk ops,
  quick-status, quick-assign)
- ticket.js: 13 calls (main save, add/update/delete comment x3, reply,
  assign, metadata update, status change, deps GET/POST/DELETE,
  attachments GET, delete attachment)
- Remove stale csrf_token from deleteAttachment body (lt.api sends the
  X-CSRF-Token header automatically)
- Fix CSS variable fallbacks in ticket.css: replace
  var(--text-primary, #f7fafc) and var(--bg-secondary, #1a202c)
  with plain var(--text-primary) and var(--bg-secondary)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:27:46 -04:00
jared e179709fc3 Add lt.autoRefresh, fix showToast in admin, clean up inline styles
- Replace all 8 showToast() calls in ApiKeysView.php with lt.toast.*
  — all toast calls in the codebase now use lt.toast directly
- Add .duplicate-list, .duplicate-meta, .duplicate-hint CSS classes to
  dashboard.css; replace inline styles in duplicate detection JS with them
- Add dashboardAutoRefresh() using lt.autoRefresh — reloads page every
  5 minutes, skipping if a modal is open or user is typing in an input
- Add REFRESH button to dashboard header that triggers lt.autoRefresh.now()
  for immediate manual refresh with timer restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:16:18 -04:00
jared b03a9cfc8c Extract hardcoded rgba colors and inline styles to CSS classes
- Add .inline-error and .inline-warning utility classes to dashboard.css
  with correctly-matched terminal palette rgba values (replaces off-palette
  rgba(231,76,60,0.1) and rgba(241,196,15,0.1))
- Add .key-generated-alert class for the new API key display frame
- Add base .dependency-item, .dependency-group h4, .dependency-item a,
  .dependency-title, .btn-small overrides to ticket.css
- Remove all inline styles from the dependency list template in ticket.js
  — layout, colors, and sizing now come from CSS classes
- Update CreateTicketView.php and ApiKeysView.php to use the new classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:08:52 -04:00
jared d44a530018 Extend lt.time.ago() to ticket view, replace showToast with lt.toast
- Add data-ts attributes to TicketView.php: ticket created/updated
  header, comment dates (inner span to preserve edited indicator),
  and all activity timeline dates
- Add initRelativeTimes() to ticket.js using lt.time.ago(); runs on
  DOMContentLoaded and every 60s to keep relative times current
- Attachment dates now use lt.time.ago() with full date in title attr
  and ts-cell span for periodic refresh
- Replace all 11 showToast() calls in ticket.js with lt.toast.* directly,
  removing reliance on the backwards-compat shim for these paths
- Add span.ts-cell and td.ts-cell CSS to both dashboard.css and ticket.css:
  dotted underline + cursor:help signals the title tooltip is available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:03:34 -04:00
jared 3c3b9d0a61 Integrate lt.time.ago() for dashboard timestamps, update README
- Add data-ts attributes to table and card view date cells so JS can
  convert them to relative time ("2h ago") while keeping the full date
  in the title attribute for hover tooltips
- Add initRelativeTimes() in dashboard.js using lt.time.ago(); runs on
  DOMContentLoaded and refreshes every 60s so times stay current
- Fix table sort for date columns to read data-ts attribute instead of
  text content (which is now relative and not sortable as a date)
- Update README: add base.css/base.js/utils.js to project structure,
  fix ascii-banner.js description, expand keyboard shortcuts table,
  add developer notes for lt.time and boot sequence behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:52:59 -04:00
jared 1046537429 Move ASCII banner into boot sequence, fix remaining UI issues
- Remove collapsible ASCII banner from dashboard (was cluttering the UI)
- Show ASCII banner in the boot overlay on first session visit, above
  the boot messages, with a 400ms pause before messages begin
- Add scroll fade indicator (green-tinted gradient edges) to .table-wrapper
  so users can see when the table is horizontally scrollable
- Fix null guards for tab switcher in ticket.js (tabEl, activeBtn)
- Fix Reset → RESET uppercase in AuditLogView and UserActivityView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:41:57 -04:00
jared d8220da1e0 Make dashboard table horizontally scrollable at smaller screen widths
- Set .table-wrapper to overflow-x: auto with touch scrolling support
- Add min-width: 900px to table to trigger scroll before columns collapse
- Set .ascii-frame-outer overflow-x: visible to avoid clipping conflict

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:35:45 -04:00
jared 021c01b3d4 Polish: uppercase all admin view button text
- AuditLogView.php: FILTER, RESET
- UserActivityView.php: APPLY, RESET
- ApiKeysView.php: GENERATE KEY, COPY, REVOKE
- WorkflowDesignerView.php: + NEW TRANSITION, EDIT, DELETE, SAVE, CANCEL
- CustomFieldsView.php: + NEW FIELD, EDIT, DELETE, SAVE, CANCEL
- TemplatesView.php: + NEW TEMPLATE, EDIT, DELETE, SAVE, CANCEL
- RecurringTicketsView.php: + NEW RECURRING TICKET, EDIT, DISABLE/ENABLE, DELETE, SAVE, CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:27:18 -04:00
jared 22cab10d5d Polish: uppercase remaining modal and pagination button text
- DashboardView.php: settings modal SAVE PREFERENCES/CANCEL, advanced search SEARCH/RESET/CANCEL
- DashboardView.php: pagination prev/next add [ « ] and [ » ] brackets
- TicketView.php: settings modal SAVE PREFERENCES/CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:19:13 -04:00
jared f0d7b9aa61 Polish: uppercase all remaining mixed-case button text
- DashboardView.php: APPLY FILTERS, CLEAR ALL, SEARCH, CHANGE STATUS, ASSIGN, PRIORITY, CLEAR, EXPORT SELECTED
- CreateTicketView.php: CREATE TICKET, CANCEL
- ticket.js: SAVE, CANCEL, REMOVE, REPLY in dynamically-generated HTML templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:14:41 -04:00
jared 3493ed78f8 Polish: uppercase button text, ASCII-safe stat icons and boot sequence
- TicketView.php: 'Edit Ticket' → 'EDIT TICKET'
- DashboardView.php: '+ New Ticket' → '+ NEW TICKET'
- DashboardView.php: stat-icon [ ✓ ] → [ OK ] (ASCII-safe)
- DashboardView.php: boot sequence '> SYSTEM READY ✓' → '> SYSTEM READY [OK]'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:10:38 -04:00
jared 90c5b3ff71 UI/UX polish: terminal design system alignment pass
Views:
- DashboardView.php: remove hardcoded [ ] from admin-badge button (CSS adds them)
- DashboardView.php: view toggle ≡/▦ → [ = ]/[ # ] (view-btn suppresses auto-brackets)
- DashboardView.php: clear-search ✗ → [ X ] (plain text, no auto-brackets on <a>)
- DashboardView.php: remove ↓ arrow emoji from export button text
- TicketView.php: tab labels → UPPERCASE (tab-btn CSS adds [ ] around text)
- TicketView.php: Edit Ticket/Clone/Add Comment/Add → title-case → UPPERCASE
- TicketView.php: reply button ↩ → [ << ] (comment-action-btn has no auto-brackets)

JavaScript:
- dashboard.js: modal/action button text all → UPPERCASE (CONFIRM/CANCEL/SAVE/ASSIGN/UPDATE/DELETE PERMANENTLY)
- dashboard.js: null guard in loadTemplate(), toggleSelectAll()
- ticket.js: null guards in addDependency(), handleFileUpload()

CSS:
- dashboard.css: z-index 1001/1002 magic numbers → var(--z-modal)/var(--z-popover)
- ticket.css: status-select hover/focus border rgba(white) → terminal palette

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:50:59 -04:00
jared 84bea80abd Fix PHP parse error and CSS/JS follow-on fixes
- DashboardView.php: fix PHP parse error on line 456/472/473/474 caused by
  escaped double-quotes {$row[\"key\"]} inside double-quoted echo strings;
  replaced with safe string concatenation . $row['key'] .
- ticket.css: fix status-select hover/focus border rgba(white) → terminal palette
- ticket.js: add null guards to addComment, togglePreview, updatePreview,
  toggleMarkdownMode, and addDependency element lookups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:44:08 -04:00
jared 2f9af856dc Fix design system violations: replace off-brand colors with terminal palette
- dashboard.css: replace all hardcoded Tailwind hex colors (#2d3748, #1a202c,
  #e2e8f0, #4a5568, #007cba, #3b82f6 etc.) in dark-mode sections and component
  styles with terminal CSS variables (--bg-*, --text-*, --border-color,
  --terminal-green/amber)
- dashboard.css: fix card-priority colors white/black → var(--bg-primary)
- dashboard.css: fix card-assignee border-radius: 50% → 0 (no circles rule)
- dashboard.css: fix mobile bottom-sheet border-radius: 12px → 0
- dashboard.css: fix search-box focus border (#007cba → var(--terminal-green))
- dashboard.css: fix save-filter button blue (#3b82f6) → terminal green
- dashboard.css: fix search-results-info blue highlight → terminal green
- dashboard.css: fix btn-bulk/btn-secondary dark-mode bootstrap colors → terminal
- ticket.css: replace comprehensive dark-mode Tailwind hex block with CSS vars
- ticket.css: fix status-select white/black text → var(--bg-primary)
- ticket.css: fix status-select.status-resolved hardcoded #28a745 → var(--status-open)
- ticket.css: fix timeline dark-mode hardcoded colors → CSS vars
- ticket.css: fix .slider:before background white → var(--bg-primary)
- ticket.css: fix .btn-danger:hover color white → var(--bg-primary)
- ticket.css: fix visibility-groups-list label border-radius: 4px → 0
- ticket.css: add will-change: opacity to age-warning/age-critical animations
- views: bump CSS version strings to v=20260319c
- views/DashboardView.php: add aria-labels to card view quick action buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:37:19 -04:00
jared 27075a62ee Fix bracket buttons rendering below text + UI/security improvements
CSS fixes:
- Fix [ ] brackets appearing below button text by replacing display:inline-flex
  with display:inline-block + white-space:nowrap on .btn — removes cross-browser
  flex pseudo-element inconsistency as root cause
- Remove conflicting .btn::before ripple block (position:absolute was overriding
  bracket content positioning)
- Remove overflow:hidden from .btn which was clipping bracket content
- Fix body::after duplicate rule causing GPU layer blink (second position:fixed
  rule re-created compositor layer, overriding display:none suppression)
- Replace all transition:all with scoped property transitions in dashboard.css,
  ticket.css, base.css (prevents full CSS property evaluation on every hover)
- Convert pulse-warning/pulse-critical keyframes from box-shadow to opacity
  animation (GPU-composited, eliminates CPU repaints at 60fps)
- Fix mobile *::before/*::after blanket content:none rule — now targets only
  decorative frame glyphs, preserving button brackets and status indicators
- Remove --terminal-green-dim override that broke .lt-btn hover backgrounds

JS fixes:
- Fix all lt.lt.toast.* double-prefix instances in dashboard.js
- Add null guard before .appendChild() on bulkAssignUser select
- Replace all remaining emoji with terminal bracket notation (dashboard.js,
  ticket.js, markdown.js)
- Migrate all toast.*() shim calls to lt.toast.* across all JS files

View fixes:
- Remove hardcoded [ ] brackets from .btn buttons (CSS now adds them)
- Replace all emoji with terminal bracket notation in all views and admin views
- Add missing CSP nonces to AuditLogView.php and UserActivityView.php script tags
- Bump CSS version strings to ?v=20260319b for cache busting

Security fixes:
- update_ticket.php: add authorization check (non-admins can only edit their own
  or assigned tickets)
- add_comment.php: validate and cast ticket_id to integer with 400 response
- clone_ticket.php: fix unconditional session_start(), add ticket ID validation,
  add internal ticket access check
- bulk_operation.php: add HTTP 401/403 status codes on auth failures
- upload_attachment.php: fix missing $conn arg in AttachmentModel constructor
- assign_ticket.php: add ticket existence check and permission verification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:20:43 -04:00
jared dd8833ee2f Restore visual effects using GPU-safe techniques (no repaint triggers)
Rules: transform/opacity = GPU composited (fine). box-shadow/text-shadow on
hover = CPU repaint (removed). Static box-shadow/text-shadow = painted once (fine).

- Buttons (.btn, .btn-base, button, .btn-primary): add will-change:transform
  for pre-promotion, add transform:translateY(-1px) on hover (GPU, no repaint),
  scope transition to include transform, remove box-shadow/text-shadow from hover
- Stat cards: add will-change:transform, add transform:translateY(-2px) on hover
- Priority badges: replace filter:blur(6px) ::after pseudo-element (permanent GPU
  layer per badge, ~20 on screen at once) with static box-shadow:0 0 6px currentColor
  on the badge itself — painted once, never changes, zero compositor overhead
- Links: replace opacity-transition ::after underline (lazy GPU layer creation on
  hover) with text-decoration:underline on hover (pure CPU paint, no GPU layer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 19:28:28 -04:00
jared ab3e77a9ba Fix blink root cause: eliminate position:fixed GPU compositing layers
Chrome promotes ALL position:fixed elements to GPU compositing layers for scroll
performance, regardless of whether they have animations. The body::before scanline
overlay (position:fixed, z-index:9999, full-viewport) and body::after watermark
(position:fixed) were both on GPU layers. Every CPU repaint from any hover state
change required a compositor re-blend pass → one-frame blink at compositor sync.

Fixes:
- Move scanlines from body::before (position:fixed) into body { background-image }
  — same visual, no separate element, no GPU layer promotion
- Set body::before { display:none } and body::after { display:none } in both
  dashboard.css and base.css
- Remove animation:matrix-rain from .stat-card:hover::before — background-position
  animation is not GPU-composited, caused CPU repaints every frame while hovered
  plus GPU texture uploads when animation started/stopped on cursor enter/exit
- Scope a { transition: all } → transition: color in base.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:23:30 -04:00
jared 68ff89b48c Fix persistent blink: scanline animation still active via base.css cascade
Root cause: removing 'animation' from dashboard.css body::before did NOT disable
the scanline — it just stopped overriding base.css which still had
'animation: scanline 8s linear infinite'. CSS cascade means the base.css value
remained active. Fixed by setting 'animation: none' explicitly in dashboard.css.

Also fix base.css (used by all pages including ticket page):
- Set body::before animation: none (removes GPU compositing layer from scanline)
- Change corner-pulse/subtle-pulse/pulse-glow/pulse-red keyframes from text-shadow
  and box-shadow animations to opacity (GPU composited, zero CPU repaint overhead)
- Change exec-running-pulse from box-shadow to opacity
- Remove box-shadow from .lt-table tr:hover, .lt-card:hover, .lt-stat-card:hover
- Remove text-shadow/box-shadow/transform from .lt-btn:hover and variants
- Remove text-shadow from a:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:12:44 -04:00
jared 328c103460 Fix ascii-frame-outer blink: eliminate all repaint-causing hover effects
- Change pulse-glow keyframes from text-shadow animation to opacity (GPU composited,
  eliminates 60fps CPU repaint that was the likely root cause of the persistent blink)
- Remove box-shadow from .quick-action-btn:hover; scope transition: all → background/color
- Remove box-shadow + background gradient + transform:translateY from .stat-card:hover;
  scope transition: all → border-color only
- Remove .stat-card::after transition and hover background change
- Remove duplicate .stat-card:hover transform:translateY block
- Remove box-shadow from .clear-search-btn:hover; scope transition: all → background/color
- Remove text-shadow from th transition (th:hover never changes text-shadow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:06:39 -04:00
jared 21ef9154e9 Fix ascii-frame-outer blink: remove scanline GPU layer and remaining repaint triggers
- Remove body::before scanline animation (transform: translateY promoted it to a
  GPU compositing layer; CPU repaints from hover states required compositor re-blend,
  causing one-frame blink at compositor sync boundary)
- Remove text-shadow and transform: translateY(-2px) from .btn-primary:hover/.create-ticket:hover
- Scope .btn-primary transition from 'all' to specific composited properties
- Remove box-shadow: inset from .banner-toggle:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:57:47 -04:00
jared 4ecd72bc04 Strip all box-shadow/text-shadow from hover states inside ascii-frame-outer
These non-composited properties force CPU repaints of the surrounding
paint region (the section itself) every time hover enters or exits.
With the fixed overlay (body::before scanline), each such repaint
requires the compositor to re-blend the layer, visible as a blink.

Removed from dashboard.css:
- btn/button/btn-base:hover: box-shadow + text-shadow
- th:hover: text-shadow
- ticket-link:hover: text-shadow
- pagination button:hover: box-shadow + transform + transition:all
- ticket-card-row:hover: box-shadow + transition:all -> background only
- .btn ripple rule: transition:all -> specific properties
- ascii-frame-outer: removed will-change/translateZ (GPU upload worse)

Removed from ticket.css:
- metadata-select:hover: box-shadow; transition:all -> border-color
- comment:hover: box-shadow
- btn:hover: box-shadow
- mention:hover: text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:50:06 -04:00
jared 368ad9b48e Promote ascii-frame-outer to GPU layer to stop hover blink
The body::before scanline overlay (position:fixed, z-index:9999) requires
the compositor to re-blend over the section every time a CPU repaint
happens inside it. Hover state entry/exit triggers these repaints, causing
a visible blink as the compositor flushes.

Fixes:
- Add will-change:transform + transform:translateZ(0) to ascii-frame-outer
  to promote it to its own GPU compositing layer, isolating its repaints
  from the scanline compositing pass
- Convert corner-pulse and subtle-pulse from text-shadow (CPU repaint)
  to opacity (GPU composited) to eliminate continuous repaint pressure
  inside the section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:35:38 -04:00
jared 3497c4cb47 Fix ascii-frame-outer blink: remove compositor layer thrashing on hover
Four root causes removed:
- body { transition: all } — forced browser to check all CSS properties
  on every hover event across the entire page
- a:not(.btn)::after underline: width+box-shadow transition replaced with
  opacity transition — width repaints paint layer, box-shadow forced parent
  section repaint; opacity is GPU-composited and doesn't repaint ancestors
- .ticket-link:hover { transform: translateX } — created/destroyed GPU
  compositor layer on every ticket ID hover; removed, scoped transition
  to specific non-layout properties
- .btn:hover { transform: translateY } in ticket.css — same layer issue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:25:18 -04:00
jared e756f8e0bb Fix ascii-frame-outer blink caused by JS/CSS hover conflict
JS mouseenter/mouseleave handlers were setting row.style.backgroundColor
inline, fighting with the CSS tr:hover rule. On mouseleave both fired
simultaneously causing a double repaint / blink. Removed the redundant
JS handlers — the CSS tr:hover transition already handles this cleanly.

Also removed body flicker animation from base.css (was still present
after being removed from dashboard.css).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:14:05 -04:00
jared fea7575ac8 Fix dashboard blink when cursor leaves ticket table section
- Remove pulse-glow-box animation and translateY from button:hover
  (infinite animation stopping abruptly caused a flash on mouse-leave)
- Scope button transition from 'all' to specific visual properties
  (prevents transform/layout changes from triggering on hover exit)
- Scope th transition from 'all' to background-color + text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:10:27 -04:00
jared 6fbba3939f Remove page-blinking CSS animations and old deploy script
- Remove .ascii-frame-outer:hover flicker animation (caused article to
  shake/blink every time cursor entered the ticket container)
- Remove body flicker animation (caused full page blink every 30s)
- Remove deploy.sh (deployment now handled by Gitea CI/CD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:04:03 -04:00
jared f3c15e2582 Fix table row blink when cursor leaves the table
transition:all was firing on every row simultaneously when the cursor
left the table. Scoped it to background-color only. Also removed the
inset box-shadow from tr:hover which forced repaint layer thrashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:55:50 -04:00
jared 51fa5a8a3c Add lt.keys.initDefaults() to audit log and user activity views
Ensures ESC/Ctrl+K/? keyboard shortcuts work consistently on all admin pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:04:50 -04:00
jared 4a838b68ca Move base.js/base.css into assets to fix auth proxy 404
/web_template/ path was being intercepted by the auth proxy at
t.lotusguild.org returning HTML instead of the actual files. Moving
base.js and base.css into /assets/js/ and /assets/css/ where static
assets are already served correctly. Updated all 10 view files and
deploy.sh accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:44:46 -04:00
jared 5545328e53 Fix deploy.sh to also sync web_template to server
base.js and base.css were returning 404 because /var/www/html/web_template
did not exist on the server. Now rsyncs /root/code/web_template/ to
/var/www/html/web_template/ before deploying the app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:36:55 -04:00
jared 8bb43c14db Guard lt.* calls when base.js unavailable to prevent crash
Wraps all lt.keys.initDefaults() calls in `if (window.lt)` guards across
6 view files. Adds `if (!window.lt) return` bail-out in keyboard-shortcuts.js
and `if (window.lt)` guard in settings.js DOMContentLoaded handler.

This prevents TypeError crashes when /web_template/base.js returns 404,
which was causing the admin menu click delegation to never register.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:34:59 -04:00
jared 92544d60ce Fix lt-modal-overlay not hidden without base.css
Add lt-modal-overlay, lt-modal, lt-btn fallback styles to dashboard.css
so modals are properly hidden (display:none) and styled even when
/web_template/base.css is not yet served. Mirrors the rules from base.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:29:20 -04:00
jared 89a685a502 Integrate web_template design system and fix security/quality issues
Security fixes:
- Add HTTP method validation to delete_comment.php (block CSRF via GET)
- Remove $_GET fallback in comment deletion (was CSRF bypass vector)
- Guard session_start() with session_status() check across API files
- Escape json_encode() data attributes with htmlspecialchars in views
- Escape inline APP_TIMEZONE config values in DashboardView/TicketView
- Validate timezone param against DateTimeZone::listIdentifiers() in index.php
- Remove Database::escape() (was using real_escape_string, not safe)
- Fix AttachmentModel hardcoded connection; inject via constructor

Backend fixes:
- Fix CommentModel bind_param type for ticket_id (s→i)
- Fix buildCommentThread orphan parent guard
- Fix StatsModel JOIN→LEFT JOIN so unassigned tickets aren't excluded
- Add ticket ID validation in BulkOperationsModel before implode()
- Add duplicate key retry in TicketModel::createTicket() for race conditions
- Wrap SavedFiltersModel default filter changes in transactions
- Add null result guards in WorkflowModel query methods

Frontend JS:
- Rewrite toast.js as lt.toast shim (base.js dependency)
- Delegate escapeHtml() to lt.escHtml()
- Rewrite keyboard-shortcuts.js using lt.keys.on()
- Migrate settings.js to lt.api.* and lt.modal.open/close()
- Migrate advanced-search.js to lt.api.* and lt.modal.open/close()
- Migrate dashboard.js fetch calls to lt.api.*; update all dynamic
  modals (bulk ops, quick actions, confirm/input) to lt-modal structure
- Migrate ticket.js fetchMentionUsers to lt.api.get()
- Remove console.log/error/warn calls from JS files

Views:
- Add /web_template/base.css and base.js to all 10 view files
- Call lt.keys.initDefaults() in DashboardView, TicketView, admin views
- Migrate all modal HTML from settings-modal/settings-content to
  lt-modal-overlay/lt-modal/lt-modal-header/lt-modal-body/lt-modal-footer
- Replace style="display:none" with aria-hidden="true" on all modals
- Replace modal open/close style.display with lt.modal.open/close()
- Update modal buttons to lt-btn lt-btn-primary/lt-btn-ghost classes
- Remove manual ESC keydown handlers (replaced by lt.keys.initDefaults)
- Fix unescaped timezone values in TicketView inline script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:22:24 -04:00
83 changed files with 18016 additions and 14988 deletions
+11
View File
@@ -26,3 +26,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1
# Timezone (default: America/New_York)
TIMEZONE=America/New_York
# LDAP / lldap (for user avatar lookups)
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_PW=
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
# How long to cache avatar images locally (seconds, default 3600)
AVATAR_CACHE_TTL=3600
+200 -59
View File
@@ -23,25 +23,28 @@ Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling,
## Design Decisions
The following features are intentionally **not planned** for this system:
- **Email Integration**: Discord webhooks are the chosen notification method
- **SLA Management**: Not required for internal infrastructure use
- **Email Integration**: Matrix (hookshot webhook) is the chosen external notification method
- **Time Tracking**: Out of scope for current requirements
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
## Core Features
### Dashboard & Ticket Management
- **View Modes**: Toggle between Table view and Kanban card view
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup (300ms delay)
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets)
- **View Modes**: Toggle between Table view and Kanban card view (drag-and-drop status changes)
- **Right Drawer Preview**: Click any ticket title to open a quick-preview panel without navigating away
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets) with live trend indicators
- **Charts**: Priority distribution donut, status breakdown donut, and category bar chart (Chart.js, CDN)
- **Team Workload**: Collapsible panel showing open ticket count per assignee with progress bars
- **Full-Text Search**: Search across tickets, descriptions, and metadata
- **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support
- **Ticket Assignment**: Assign tickets to specific users with quick-assign from dashboard
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
- **Advanced Search**: Date ranges (Flatpickr), priority ranges, user filters
- **Saved Filters**: Save and recall filter presets; quick-switch pills above the table
- **Column Visibility**: Toggle which dashboard table columns are shown; persisted in localStorage
- **Ticket Assignment**: Assign tickets to specific users with typeahead search
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators and status dots
- **Custom Categories**: Hardware, Software, Network, Security, General
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
- **Export**: Export selected tickets to CSV or JSON format
- **Skeleton Loaders**: Loading placeholders during filter changes and data refresh
- **Export**: Export filtered tickets to CSV or JSON format
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
### Ticket Visibility Levels
@@ -51,19 +54,24 @@ The following features are intentionally **not planned** for this system:
### Workflow Management
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
- **Comment Requirements**: Transitions that require a comment open an inline modal before committing the change
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
- **Workflow Validation**: Server-side validation prevents invalid status changes
- **Admin Controls**: Certain transitions can require admin privileges
- **Comment Requirements**: Optional comment requirements for specific transitions
### Collaboration Features
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
- **@Mentions**: Tag users in comments with autocomplete
- **@Mentions**: Tag users in comments with `@` autocomplete (typeahead); triggers Matrix notification to mentioned user
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
- **Auto-linking**: URLs in comments are automatically converted to clickable links
- **File Attachments**: Upload files to tickets with drag-and-drop support
- **File Attachments**: Upload files to tickets with drag-and-drop; image attachments display as thumbnails with lightbox zoom
- **Ticket Cloning**: Duplicate any ticket with a single click; auto-links as `relates_to`
- **Ticket Dependencies**: Link tickets as blocks / blocked-by / relates-to / duplicates
- **Activity Timeline**: Complete audit trail of all ticket changes
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h)
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
### Ticket Templates
- **Template Management**: Admin UI at `/admin/templates` to create/edit templates
@@ -92,45 +100,54 @@ The following features are intentionally **not planned** for this system:
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
- **User Groups**: Groups displayed in settings modal, used for visibility
- **User Avatars**: JPEG avatars fetched from lldap via LDAP; cached locally (`/api/user_avatar.php`)
- **User Activity**: View per-user stats at `/admin/user-activity`
- **Session Management**: Secure PHP session handling with timeout
### Bulk Actions (Admin Only)
- **Bulk Close**: Close multiple tickets at once
- **Bulk Assign**: Assign multiple tickets to a user
- **Bulk Assign**: Assign multiple tickets to a user (typeahead search)
- **Bulk Priority**: Change priority for multiple tickets
- **Bulk Status**: Change status for multiple tickets
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
### Admin Pages
Access all admin pages via the **Admin dropdown** in the dashboard header.
### In-App Notifications
- **Notification Bell**: Header bell icon with unread count badge; polls every 60 s
- **Notification Sources**: Ticket assigned to you, comment on your ticket, status change on watched ticket, @mention
- **Mark All Read**: Click the bell or "Mark all read" to clear the badge
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
| Route | Description |
|-------|-------------|
| `/admin/templates` | Create and edit ticket templates |
| `/admin/workflow` | Visual workflow transition designer |
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
| `/admin/custom-fields` | Define custom fields per category |
| `/admin/user-activity` | View per-user activity statistics |
| `/admin/audit-log` | Browse all audit log entries |
| `/admin/api-keys` | Generate and manage API keys |
### Matrix Notifications (hookshot)
- **Ticket Created**: Fires when any ticket is created (manual or via API)
- **Status Changed**: Fires on every status transition
- **@Mentions**: Mentioned users receive a direct Matrix notification
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
- **Comments**: Optional — set `MATRIX_NOTIFY_COMMENTS=1` to enable
- **Watcher Alerts**: Watchers receive Matrix notifications on status changes (resolved via Synapse Admin API)
- **Rich Payloads**: JSON payloads sent to hookshot generic webhook; format ticket links using `APP_DOMAIN`
### Notifications
- **Discord Integration**: Webhook notifications for ticket creation and updates
- **Rich Embeds**: Color-coded priority indicators and ticket links
- **Dynamic URLs**: Ticket links adapt to the server hostname (set `APP_DOMAIN` in `.env`)
### Command Palette (Ctrl+K)
- **Global Access**: Available on every page via `Ctrl+K` or `⌘K` button in header
- **Quick Navigation**: Dashboard, New Ticket, My Tickets, admin pages
- **Recent Tickets**: Last 5 viewed tickets (stored in localStorage)
- **Filter Shortcuts**: Apply common filters directly from palette
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd + K` | Open command palette (global) |
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
| `Ctrl/Cmd + S` | Save changes (ticket page) |
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
| `N` | New ticket (dashboard) |
| `J` / `K` | Next / previous row (dashboard table) |
| `Enter` | Open selected ticket (dashboard) |
| `G` then `D` | Go to dashboard |
| `1``4` | Quick status change (ticket page) |
| `ESC` | Cancel edit / close modal |
| `?` | Show keyboard shortcuts help |
### Security Features
- **CSRF Protection**: Token-based protection with constant-time comparison
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
@@ -139,6 +156,31 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
## Automated Ticket Creation (hwmonDaemon)
[hwmonDaemon](https://code.lotusguild.org/LotusGuild/hwmonDaemon) runs on all servers and creates tickets automatically for hardware/health issues. It calls the **standalone API endpoint** at the document root:
```
POST /create_ticket_api.php
Authorization: Bearer <api_key>
Content-Type: application/json
{
"title": "[hostname][auto][production][hardware][single-node] SMART issues on /dev/sda",
"description": "...",
"priority": "2",
"category": "Hardware",
"type": "Issue"
}
```
**Key behaviours:**
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
- Matrix notification sent automatically after ticket creation
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
## Technical Architecture
### Backend
@@ -153,6 +195,8 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
- **Markdown**: Custom markdown parser with toolbar
- **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
- **Chart.js**: CDN-loaded on dashboard only — priority/status/category charts
- **Flatpickr**: CDN-loaded on dashboard only — date range filter pickers
### Database Tables
@@ -162,9 +206,10 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `ticket_comments` | Markdown-supported comments |
| `ticket_attachments` | File attachment metadata |
| `ticket_dependencies` | Ticket relationships |
| `ticket_watchers` | Per-user ticket subscriptions |
| `users` | User accounts with groups |
| `user_preferences` | User settings |
| `audit_log` | Complete audit trail |
| `user_preferences` | User settings (rows per page, notification opts, notif_last_seen) |
| `audit_log` | Complete audit trail (also powers in-app notifications) |
| `status_transitions` | Workflow configuration |
| `ticket_templates` | Reusable templates |
| `recurring_tickets` | Scheduled tickets |
@@ -196,9 +241,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments |
| `/api/bulk_operation.php` | POST | Perform bulk operations |
@@ -215,6 +262,14 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
| `/api/manage_templates.php` | CRUD | Templates (admin) |
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
| `/api/user_preferences.php` | GET/POST | User preferences |
| `/api/notifications.php` | GET/POST | In-app notifications (bell) |
| `/api/user_avatar.php` | GET | User avatar from lldap (cached JPEG) |
| `/api/audit_log.php` | GET | Audit log entries (admin) |
| `/api/watch_ticket.php` | POST | Watch/unwatch a ticket |
| `/api/health.php` | GET | Health check |
## Project Structure
@@ -223,8 +278,12 @@ tinker_tickets/
├── api/
│ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user
│ ├── audit_log.php # GET: Audit log entries (admin)
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
│ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── clone_ticket.php # POST: Clone an existing ticket
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download with visibility check
@@ -232,27 +291,36 @@ tinker_tickets/
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
│ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── notifications.php # GET/POST: In-app notification bell
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── saved_filters.php # CRUD: Saved filter combinations
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
── upload_attachment.php # GET/POST: List or upload attachments
── upload_attachment.php # GET/POST: List or upload attachments
│ ├── user_avatar.php # GET: LDAP avatar proxy with disk cache
│ ├── user_preferences.php # GET/POST: User preferences
│ └── watch_ticket.php # POST: Watch/unwatch a ticket
├── assets/
│ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling
│ ├── js/
│ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar + charts
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
│ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility
│ │ ── toast.js # Toast notifications
│ │ ├── ticket.js # Ticket + comments + visibility + @mention
│ │ ── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/
│ └── favicon.png
├── config/
@@ -263,12 +331,17 @@ tinker_tickets/
├── cron/
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
── ResponseHelper.php # Standardized JSON responses
── CacheHelper.php # File-based cache (stats, avatars)
│ ├── Database.php # Centralized mysqli connection
│ ├── NotificationHelper.php # Matrix hookshot webhook events
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
├── middleware/
│ ├── ApiKeyAuth.php # Bearer token auth for external API (hwmonDaemon)
│ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline
@@ -277,16 +350,20 @@ tinker_tickets/
│ ├── CustomFieldModel.php # Custom field definitions/values
│ ├── DependencyModel.php # Ticket dependencies
│ ├── RecurringTicketModel.php # Recurring ticket schedules
│ ├── StatsModel.php # Dashboard statistics
│ ├── SavedFiltersModel.php # Saved filter combinations
│ ├── StatsModel.php # Dashboard statistics (cached)
│ ├── TemplateModel.php # Ticket templates
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── scripts/
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
│ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage
│ └── avatars/ # lldap avatar disk cache
├── views/
│ ├── admin/
│ │ ├── ApiKeysView.php # API key management
@@ -297,9 +374,12 @@ tinker_tickets/
│ │ ├── UserActivityView.php # User activity report
│ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar
── TicketView.php # Ticket view with visibility editing
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
── layout_footer.php # Shared footer (notification polling, boot sequence)
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
├── .env # Environment variables (GITIGNORED)
├── create_ticket_api.php # External API endpoint (hwmonDaemon, API-key auth)
├── README.md # This file
└── index.php # Main router
```
@@ -332,26 +412,60 @@ DB_HOST=your_db_host
DB_USER=your_db_user
DB_PASS=your_password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York
```
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
Matrix notification variables (all optional):
```env
# hookshot generic webhook URL — send events to Matrix room
MATRIX_WEBHOOK_URL=https://matrix.lotusguild.org/_hookshot/webhook/...
# Comma-separated Matrix user IDs to @mention on new tickets / status changes
MATRIX_NOTIFY_USERS=@jared:matrix.lotusguild.org,@ops:matrix.lotusguild.org
# Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
MATRIX_DOMAIN=matrix.lotusguild.org
# Synapse internal URL and admin token (used to resolve usernames → Matrix IDs for watcher DMs)
SYNAPSE_ADMIN_URL=http://10.10.10.29:8008
SYNAPSE_ADMIN_TOKEN=your_synapse_admin_token
# Optional: send Matrix notification on comments and/or assignments
MATRIX_NOTIFY_COMMENTS=0
MATRIX_NOTIFY_ASSIGNMENTS=1
```
LDAP/avatar variables (optional):
```env
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_PW=your_bind_password
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
AVATAR_CACHE_TTL=3600
```
**Note**: `APP_DOMAIN` is required for Matrix webhook ticket links to work correctly. Without it, links will default to localhost.
### 2. Cron Jobs
Add to crontab for recurring tickets:
Add to crontab for recurring tickets and optional cleanup:
```bash
# Run every hour to create scheduled recurring tickets
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
# Optional: clean up orphaned uploads weekly
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
```
### 3. File Uploads
Ensure the `uploads/` directory exists and is writable:
```bash
mkdir -p /path/to/tinkertickets/uploads
mkdir -p /path/to/tinkertickets/uploads/avatars
chown www-data:www-data /path/to/tinkertickets/uploads
chmod 755 /path/to/tinkertickets/uploads
```
@@ -366,6 +480,16 @@ Tinker Tickets uses Authelia for SSO. User information is passed via headers:
Admin users must be in the `admin` group in LLDAP.
### 5. hwmonDaemon API Key
1. Go to `/admin/api-keys` and generate a new key named e.g. "hwmonDaemon"
2. Copy the displayed key (shown only once)
3. On each monitored server, create `/etc/hwmonDaemon/.env`:
```env
TICKET_API_KEY=your_generated_key
TICKET_API_URL=http://10.10.10.45/create_ticket_api.php
```
## Developer Notes
Key conventions and gotchas for working with this codebase:
@@ -375,38 +499,54 @@ Key conventions and gotchas for working with this codebase:
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values
10. **API routing**: All API endpoints must be registered in `index.php` router
11. **Session in APIs**: `RateLimitMiddleware` starts the session — do not call `session_start()` again
11. **Session in APIs**: `RateLimitMiddleware` starts the session — guard subsequent `session_start()` calls with `if (session_status() === PHP_SESSION_NONE)`
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
13. **Discord URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
13. **Matrix URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Matrix notifications
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60 s; full date is always in the `title` attribute for hover
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this).
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic to prevent duplicate hw-alert tickets within 24 h.
## File Reference
| File | Purpose |
|------|---------|
| `index.php` | Main router for all routes |
| `create_ticket_api.php` | External API (hwmonDaemon) — Bearer token auth, deduplication |
| `config/config.php` | Config loader + .env parsing |
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
| `api/notifications.php` | In-app notification bell — reads from audit_log |
| `api/user_avatar.php` | LDAP avatar proxy with disk cache |
| `api/download_attachment.php` | File downloads with visibility check |
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
| `models/ApiKeyModel.php` | API key generation and validation |
| `models/StatsModel.php` | Dashboard statistics (60 s cache; invalidated on ticket changes) |
| `middleware/ApiKeyAuth.php` | Bearer token authentication for external API |
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
| `assets/js/ticket.js` | Ticket UI, visibility editing |
| `helpers/NotificationHelper.php` | Matrix hookshot webhook events |
| `helpers/SynapseHelper.php` | Username → Matrix ID resolution via Synapse admin API |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions, charts, command palette |
| `assets/js/ticket.js` | Ticket UI, @mention autocomplete, lightbox, visibility editing |
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar, charts, workload panel |
| `assets/css/ticket.css` | Ticket view: SLA progress, attachment thumbnails, timeline |
## Security Implementations
@@ -414,11 +554,12 @@ Key conventions and gotchas for working with this codebase:
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`) |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
| Session Security | Fixation prevention, secure cookies, session timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME type validation |
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
| Visibility | Enforced on ticket views, downloads, and bulk operations |
| API Key Auth | SHA-256 hashed keys stored in DB; Bearer token auth for external API |
## License
+61 -1
View File
@@ -28,9 +28,14 @@ try {
require_once $commentModelPath;
require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -60,7 +65,32 @@ try {
throw new Exception("Invalid JSON data received");
}
$ticketId = $data['ticket_id'];
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
if ($ticketId <= 0) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
exit;
}
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models
$commentModel = new CommentModel($conn);
@@ -95,6 +125,32 @@ try {
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
$mentionedUsernames = array_column($mentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn, $ticketId, $ticketTitle, 'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function($u) {
return $u['username'];
@@ -110,6 +166,9 @@ try {
ob_end_clean();
// Return JSON response
if ($result['success']) {
http_response_code(201);
}
header('Content-Type: application/json');
echo json_encode($result);
@@ -121,6 +180,7 @@ try {
error_log("Add comment API error: " . $e->getMessage());
// Return error response
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
+50 -3
View File
@@ -3,13 +3,22 @@ require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = $data['ticket_id'] ?? null;
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
$assignedTo = $data['assigned_to'] ?? null;
if (!$ticketId) {
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit;
}
@@ -18,6 +27,21 @@ $ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
@@ -29,6 +53,7 @@ if ($assignedTo === null || $assignedTo === '') {
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit;
}
@@ -37,7 +62,29 @@ if ($assignedTo === null || $assignedTo === '') {
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay
);
}
}
}
echo json_encode(['success' => $success]);
if (!$success) {
http_response_code(500);
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
apiRespond(['success' => true]);
}
+2 -2
View File
@@ -71,8 +71,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Normal JSON response for filtered logs
try {
// Get pagination parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
$offset = ($page - 1) * $limit;
// Build filters
+14
View File
@@ -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;
}
+15 -8
View File
@@ -3,7 +3,6 @@
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
@@ -14,6 +13,7 @@ header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
@@ -32,6 +32,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check admin status - bulk operations are admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit;
}
@@ -48,13 +49,15 @@ if (!$operationType || empty($ticketIds)) {
exit;
}
// Validate ticket IDs are integers
foreach ($ticketIds as $ticketId) {
if (!is_numeric($ticketId)) {
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']);
// Validate ticket IDs are positive integers
$ticketIds = array_values(array_filter(array_map(function($id) {
$int = (int)$id;
return ($int > 0 && (string)$int === (string)$id) ? $int : null;
}, $ticketIds)));
if (empty($ticketIds)) {
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
@@ -98,14 +101,18 @@ if (!$operationId) {
// Process the bulk operation
$result = $bulkOpsModel->processBulkOperation($operationId);
$conn->close();
if (isset($result['error'])) {
$conn->close();
echo json_encode([
'success' => false,
'error' => $result['error']
]);
} else {
// Invalidate stats cache so dashboard tiles reflect changes immediately
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
$conn->close();
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)";
+11 -1
View File
@@ -7,6 +7,7 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
@@ -30,6 +31,10 @@ $searchTerm = '%' . $title . '%';
// Get SOUNDEX of title
$soundexTitle = soundex($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets
@@ -38,11 +43,16 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
OR SOUNDEX(title) = ?
)
AND status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY created_at DESC
LIMIT 10";
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
$stmt = $conn->prepare($sql);
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
+20 -2
View File
@@ -7,6 +7,8 @@
ini_set('display_errors', 0);
error_reporting(E_ALL);
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -17,7 +19,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -50,8 +54,14 @@ try {
exit;
}
$sourceTicketId = $data['ticket_id'];
$sourceTicketId = (int)$data['ticket_id'];
if ($sourceTicketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
exit;
}
$userId = $_SESSION['user']['user_id'];
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
// Get database connection
$conn = Database::getConnection();
@@ -66,6 +76,13 @@ try {
exit;
}
// Verify the user can access this ticket using centralized visibility logic
if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
exit;
}
// Prepare cloned ticket data
$clonedTicketData = [
'title' => '[CLONE] ' . $sourceTicket['title'],
@@ -94,7 +111,8 @@ try {
$dependencyModel = new DependencyModel($conn);
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
echo json_encode([
'success' => true,
'new_ticket_id' => $result['ticket_id'],
+13 -1
View File
@@ -16,7 +16,7 @@ try {
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
// Check authentication
session_start();
if (session_status() === PHP_SESSION_NONE) session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -66,23 +66,35 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->createDefinition($data);
echo json_encode($result);
break;
case 'PUT':
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->updateDefinition($id, $data);
echo json_encode($result);
break;
case 'DELETE':
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
+22 -11
View File
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
@@ -50,15 +51,13 @@ if (!CsrfMiddleware::validateToken($csrfToken)) {
}
// Get attachment ID
$attachmentId = $input['attachment_id'] ?? null;
if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = isset($input['attachment_id']) ? (int)$input['attachment_id'] : 0;
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($input['attachment_id'] ?? '')) {
ResponseHelper::error('Valid attachment ID is required');
}
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);
@@ -66,18 +65,30 @@ try {
ResponseHelper::notFound('Attachment not found');
}
// Check permission
// Verify user can access the parent ticket
$ticketModel = new TicketModel(Database::getConnection());
$ticket = $ticketModel->getTicketById((int)$attachment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Attachment not found');
}
// Check permission (must be uploader or admin)
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
ResponseHelper::forbidden('You do not have permission to delete this attachment');
}
// Delete the file
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
// Delete the file — use realpath() to prevent path traversal
$uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
$filePath = $uploadDir . '/' . (int)$attachment['ticket_id'] . '/' . $attachment['filename'];
$realPath = realpath($filePath);
if (file_exists($filePath)) {
if (!unlink($filePath)) {
if ($realPath !== false) {
// Ensure the resolved path is still inside the upload directory
if (strncmp($realPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) !== 0) {
ResponseHelper::forbidden('Access denied');
}
if (!unlink($realPath)) {
ResponseHelper::serverError('Failed to delete file');
}
}
+15 -3
View File
@@ -21,8 +21,19 @@ try {
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'POST' && $method !== 'DELETE') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -48,9 +59,9 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id'])) {
// Try query params
if (isset($_GET['comment_id'])) {
$data = ['comment_id' => $_GET['comment_id']];
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
if (isset($_POST['comment_id'])) {
$data = ['comment_id' => $_POST['comment_id']];
} else {
throw new Exception("Missing required field: comment_id");
}
@@ -104,6 +115,7 @@ try {
} catch (Exception $e) {
ob_end_clean();
error_log("Delete comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
+4 -6
View File
@@ -22,18 +22,16 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
}
// Get attachment ID
$attachmentId = $_GET['id'] ?? null;
if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($_GET['id'] ?? '')) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
exit;
}
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);
@@ -75,7 +73,7 @@ try {
$realUploadDir = realpath($uploadDir);
$realFilePath = realpath($filePath);
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) {
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
+81 -2
View File
@@ -19,9 +19,11 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
session_start();
if (session_status() === PHP_SESSION_NONE) { session_start(); }
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
header('Content-Type: application/json');
http_response_code(401);
@@ -41,6 +43,7 @@ try {
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
$singleId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : null;
// Initialize model
$ticketModel = new TicketModel($conn);
@@ -149,10 +152,86 @@ try {
], JSON_PRETTY_PRINT);
exit;
} elseif ($format === 'full') {
// Full single-ticket export: ticket + all comments + audit timeline
if (!$singleId) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'format=full requires a ticket_id parameter']);
exit;
}
$ticket = $ticketModel->getTicketById($singleId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$auditLogModel = new AuditLogModel($conn);
// Load flat comment list (no threading nesting in export)
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
$comments = array_map(function($c) {
return [
'comment_id' => $c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
'created_at' => $c['created_at'],
'updated_at' => $c['updated_at'] ?? null,
'comment_text' => $c['comment_text'],
'parent_comment_id' => $c['parent_comment_id'] ?? null,
];
}, $rawComments);
$timelineOut = array_map(function($row) {
$details = $row['details'];
if (is_string($details)) {
$details = json_decode($details, true) ?? $details;
}
return [
'action' => $row['action_type'],
'entity' => $row['entity_type'],
'actor' => $row['display_name'] ?? $row['username'] ?? 'System',
'details' => $details,
'created_at' => $row['created_at'],
];
}, $timeline);
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="ticket_' . $ticket['ticket_id'] . '_full_' . date('Y-m-d_His') . '.json"');
header('Cache-Control: no-cache, must-revalidate');
echo json_encode([
'exported_at' => date('c'),
'ticket' => [
'ticket_id' => $ticket['ticket_id'],
'title' => $ticket['title'],
'status' => $ticket['status'],
'priority' => 'P' . $ticket['priority'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'visibility' => $ticket['visibility'] ?? 'public',
'description' => $ticket['description'],
'created_by' => $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
'assigned_to' => $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
'created_at' => $ticket['created_at'],
'updated_at' => $ticket['updated_at'],
'closed_at' => $ticket['closed_at'] ?? null,
],
'comments' => $comments,
'comment_count' => count($comments),
'timeline' => $timelineOut,
], JSON_PRETTY_PRINT);
exit;
} else {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
exit;
}
+1 -1
View File
@@ -19,7 +19,7 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
session_start();
if (session_status() === PHP_SESSION_NONE) session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Get Comments API
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0;
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$total = $commentModel->getCommentCount($ticketId);
$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset);
echo json_encode([
'success' => true,
'comments' => $comments,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
+3 -6
View File
@@ -11,7 +11,7 @@ require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
try {
session_start();
if (session_status() === PHP_SESSION_NONE) session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
@@ -24,18 +24,15 @@ try {
}
// Get template ID from query parameter
$templateId = $_GET['template_id'] ?? null;
$templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0;
if (!$templateId || !is_numeric($templateId)) {
if ($templateId <= 0 || (string)$templateId !== (string)($_GET['template_id'] ?? '')) {
ErrorHandler::sendValidationError(
['template_id' => 'Valid template ID required'],
'Invalid request'
);
}
// Cast to integer for safety
$templateId = (int)$templateId;
// Get template
$conn = Database::getConnection();
$templateModel = new TemplateModel($conn);
+18 -7
View File
@@ -16,7 +16,7 @@ try {
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
// Check authentication
session_start();
if (session_status() === PHP_SESSION_NONE) session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -70,6 +70,10 @@ try {
echo json_encode($result);
} else {
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type']) || empty($data['title_template'])) {
echo json_encode(['success' => false, 'error' => 'schedule_type and title_template are required']);
exit;
}
// Calculate next run time
$nextRun = calculateNextRun(
@@ -94,6 +98,10 @@ try {
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type'])) {
echo json_encode(['success' => false, 'error' => 'Invalid request data']);
exit;
}
// Recalculate next run time if schedule changed
$nextRun = calculateNextRun(
@@ -139,18 +147,21 @@ function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
break;
case 'weekly':
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[$scheduleDay] ?? 'Monday';
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $time);
break;
case 'monthly':
$day = max(1, min(28, (int)$scheduleDay));
$day = max(1, min(31, (int)$scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
$next->setDate($next->format('Y'), $next->format('m'), $day);
list($h, $m) = explode(':', $time);
$next->setTime((int)$h, (int)$m, 0);
// Clamp to last day of target month (handles Feb, 30-day months)
$daysInMonth = (int)$next->format('t');
$day = min($day, $daysInMonth);
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
$parts = explode(':', $time . ':00'); // ensure at least H:M
$next->setTime((int)$parts[0], (int)$parts[1], 0);
break;
default:
+53 -15
View File
@@ -15,7 +15,7 @@ try {
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication
session_start();
if (session_status() === PHP_SESSION_NONE) session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -73,17 +73,36 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("INSERT INTO ticket_templates
(template_name, title_template, description_template, category, type, default_priority, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param('sssssii',
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'] ?? 4,
$data['is_active'] ?? 1
$templateName,
$titleTemplate,
$description,
$category,
$type,
$priority,
$isActive
);
if ($stmt->execute()) {
@@ -103,18 +122,37 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("UPDATE ticket_templates SET
template_name = ?, title_template = ?, description_template = ?,
category = ?, type = ?, default_priority = ?, is_active = ?
WHERE template_id = ?");
$stmt->bind_param('sssssiii',
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'] ?? 4,
$data['is_active'] ?? 1,
$templateName,
$titleTemplate,
$description,
$category,
$type,
$priority,
$isActive,
$id
);
+25 -16
View File
@@ -17,7 +17,7 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
session_start();
if (session_status() === PHP_SESSION_NONE) session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -79,15 +79,20 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
exit;
}
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
VALUES (?, ?, ?, ?, ?)");
$stmt->bind_param('ssiii',
$data['from_status'],
$data['to_status'],
$data['requires_comment'] ?? 0,
$data['requires_admin'] ?? 0,
$data['is_active'] ?? 1
);
$wf_from = $data['from_status'];
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
if ($stmt->execute()) {
$transitionId = $conn->insert_id;
@@ -117,17 +122,21 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
exit;
}
$stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?");
$stmt->bind_param('ssiiii',
$data['from_status'],
$data['to_status'],
$data['requires_comment'] ?? 0,
$data['requires_admin'] ?? 0,
$data['is_active'] ?? 1,
$id
);
$wf_from = $data['from_status'];
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
$success = $stmt->execute();
if ($success) {
+201
View File
@@ -0,0 +1,201 @@
<?php
/**
* Notifications API
*
* GET → returns recent notifications for the current user (last 7 days, max 30)
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
*
* Notifications are derived from audit_log:
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
* - Status changes on watched (via ticket_watchers)
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
// ── POST: mark all read (update last_seen timestamp) ──────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
if (($data['action'] ?? '') === 'mark_read') {
$prefsModel->setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s'));
apiRespond(['success' => true]);
} else {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'Unknown action']);
}
exit;
}
// ── GET: fetch notifications ──────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Get last_seen timestamp (when user last marked all read)
$prefs = $prefsModel->getUserPreferences($userId);
$lastSeen = $prefs['notif_last_seen'] ?? null;
// Username for @mention detection
$myUsername = $currentUser['username'] ?? '';
// Query 1: Tickets assigned to me (events from other users)
$assignSql = "SELECT
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = 'assign'
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE ?
ORDER BY al.created_at DESC
LIMIT 15";
$assignLike = '%"assigned_to":' . $userId . '%';
$stmt = $conn->prepare($assignSql);
$stmt->bind_param('is', $userId, $assignLike);
$stmt->execute();
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Query 2: Comments on tickets I own or watch (events from other users)
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
// Step A: ticket IDs the current user owns or watches
$myTicketIds = [];
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
$stmt = $conn->prepare($myTicketsSql);
$stmt->bind_param('ii', $userId, $userId);
$stmt->execute();
$mtResult = $stmt->get_result();
while ($mtRow = $mtResult->fetch_assoc()) { $myTicketIds[(int)$mtRow['ticket_id']] = true; }
$stmt->close();
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
$stmt = $conn->prepare($watchedSql);
$stmt->bind_param('i', $userId);
$stmt->execute();
$wResult = $stmt->get_result();
while ($wRow = $wResult->fetch_assoc()) { $myTicketIds[(int)$wRow['ticket_id']] = true; }
$stmt->close();
// Step B: fetch recent comment audit events not by the current user
$commentSql = "SELECT
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = 'create'
AND al.entity_type = 'comment'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY al.created_at DESC
LIMIT 50";
$stmt = $conn->prepare($commentSql);
$stmt->bind_param('i', $userId);
$stmt->execute();
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Step C: filter to only comments on tickets the current user owns/watches
$commentRows = [];
foreach ($rawCommentRows as $rawRow) {
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
$tid = (int)($d['ticket_id'] ?? 0);
if ($tid > 0 && isset($myTicketIds[$tid])) {
$commentRows[] = $rawRow;
if (count($commentRows) >= 15) break;
}
}
// Query 3: Status changes on watched tickets (from other users)
$statusSql = "SELECT DISTINCT
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
WHERE al.action_type = 'update'
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE '%\"status\":%'
ORDER BY al.created_at DESC
LIMIT 10";
$stmt = $conn->prepare($statusSql);
$stmt->bind_param('ii', $userId, $userId);
$stmt->execute();
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Merge, deduplicate by log_id, sort by created_at desc
$all = [];
$seen = [];
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
$id = (int)$row['log_id'];
if (isset($seen[$id])) continue;
$seen[$id] = true;
$all[] = $row;
}
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$all = array_slice($all, 0, 30);
// Format for response
$notifications = [];
foreach ($all as $row) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
// Comment rows: entity_id is the comment_id; real ticket_id is in details
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$ticketId = ($actionType === 'comment')
? (int)($details['ticket_id'] ?? 0)
: (int)$row['entity_id'];
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
// Build human-readable title
$title = match($actionType) {
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
'update' => (function() use ($row, $details, $ticketId) {
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
return "{$row['actor_name']} changed status on #{$ticketId}: {$from}{$to}";
})(),
default => "{$row['actor_name']} updated ticket #{$ticketId}",
};
$ticketTitle = $details['title'] ?? null;
if ($ticketTitle) {
$title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : '');
}
$notifications[] = [
'log_id' => (int)$row['log_id'],
'ticket_id' => $ticketId,
'title' => $title,
'created_at' => $row['created_at'],
'is_read' => $isRead,
'action' => $actionType,
'url' => "/ticket/{$ticketId}",
];
}
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
apiRespond([
'success' => true,
'notifications' => $notifications,
'unread_count' => $unreadCount,
'last_seen' => $lastSeen,
]);
+1 -1
View File
@@ -19,7 +19,7 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
session_start();
if (session_status() === PHP_SESSION_NONE) session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
+19 -19
View File
@@ -17,23 +17,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$filter = $filtersModel->getFilter($filterId, $userId);
if ($filter) {
echo json_encode(['success' => true, 'filter' => $filter]);
apiRespond(['success' => true, 'filter' => $filter]);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Filter not found']);
apiRespond(['success' => false, 'error' => 'Filter not found']);
}
} else if (isset($_GET['default'])) {
// Get default filter
$filter = $filtersModel->getDefaultFilter($userId);
echo json_encode(['success' => true, 'filter' => $filter]);
apiRespond(['success' => true, 'filter' => $filter]);
} else {
// Get all filters
$filters = $filtersModel->getUserFilters($userId);
echo json_encode(['success' => true, 'filters' => $filters]);
apiRespond(['success' => true, 'filters' => $filters]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
apiRespond(['success' => false, 'error' => 'Failed to fetch filters']);
}
exit;
}
@@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
exit;
}
@@ -55,16 +55,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate filter name
if (empty($filterName) || strlen($filterName) > 100) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
apiRespond(['success' => false, 'error' => 'Invalid filter name']);
exit;
}
try {
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
apiRespond(['success' => false, 'error' => 'Failed to save filter']);
}
exit;
}
@@ -75,7 +75,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
exit;
}
@@ -85,10 +85,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (isset($data['set_default']) && $data['set_default'] === true) {
try {
$result = $filtersModel->setDefaultFilter($filterId, $userId);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
apiRespond(['success' => false, 'error' => 'Failed to set default filter']);
}
exit;
}
@@ -96,7 +96,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
// Handle full filter update
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
exit;
}
@@ -106,10 +106,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
try {
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
apiRespond(['success' => false, 'error' => 'Failed to update filter']);
}
exit;
}
@@ -120,7 +120,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
exit;
}
@@ -128,14 +128,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
try {
$result = $filtersModel->deleteFilter($filterId, $userId);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
apiRespond(['success' => false, 'error' => 'Failed to delete filter']);
}
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
+56
View File
@@ -67,6 +67,7 @@ require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json');
@@ -77,6 +78,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
}
$userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
@@ -99,6 +101,7 @@ if ($tableCheck->num_rows === 0) {
try {
$dependencyModel = new DependencyModel($conn);
$auditLog = new AuditLogModel($conn);
$ticketModel = new TicketModel($conn);
} catch (Exception $e) {
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
ResponseHelper::serverError('Failed to initialize required components');
@@ -116,6 +119,12 @@ switch ($method) {
ResponseHelper::error('Ticket ID required');
}
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try {
$dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId);
@@ -134,6 +143,10 @@ switch ($method) {
// Add a new dependency
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$ticketId = $data['ticket_id'] ?? null;
$dependsOnId = $data['depends_on_id'] ?? null;
$type = $data['dependency_type'] ?? 'blocks';
@@ -142,6 +155,16 @@ switch ($method) {
ResponseHelper::error('Both ticket_id and depends_on_id are required');
}
// Verify user can access both tickets before creating dependency
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
ResponseHelper::notFound('Target ticket not found');
}
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
if ($result['success']) {
@@ -162,6 +185,10 @@ switch ($method) {
// Remove a dependency
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$dependencyId = $data['dependency_id'] ?? null;
// Alternative: delete by ticket IDs
@@ -170,6 +197,18 @@ switch ($method) {
$dependsOnId = $data['depends_on_id'];
$type = $data['dependency_type'] ?? 'blocks';
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes, true)) {
ResponseHelper::error('Invalid dependency type');
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
if ($result) {
@@ -183,6 +222,23 @@ switch ($method) {
ResponseHelper::error('Failed to remove dependency');
}
} elseif ($dependencyId) {
// Look up dependency to verify ticket access before deletion
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
$depLookupStmt = $conn->prepare($depLookupSql);
$depLookupStmt->bind_param("i", $dependencyId);
$depLookupStmt->execute();
$depRow = $depLookupStmt->get_result()->fetch_assoc();
$depLookupStmt->close();
if (!$depRow) {
ResponseHelper::notFound('Dependency not found');
}
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
ResponseHelper::forbidden('Access denied');
}
$result = $dependencyModel->removeDependency($dependencyId);
if ($result) {
+3
View File
@@ -22,7 +22,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -102,6 +104,7 @@ try {
} catch (Exception $e) {
ob_end_clean();
error_log("Update comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
+85 -5
View File
@@ -26,9 +26,12 @@ try {
require_once $commentModelPath;
require_once $auditLogModelPath;
require_once $workflowModelPath;
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -51,20 +54,24 @@ try {
// Updated controller class that handles partial updates
class ApiTicketController {
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false) {
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data) {
@@ -77,6 +84,26 @@ try {
];
}
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin
&& (int)$currentTicket['created_by'] !== (int)$this->userId
&& (int)$currentTicket['assigned_to'] !== (int)$this->userId
) {
return [
'success' => false,
'error' => 'Permission denied'
];
}
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
@@ -153,18 +180,61 @@ try {
];
}
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log(
$this->userId, 'update', 'ticket', (string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
}
// Log ticket update to audit log
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully'
];
}
@@ -193,7 +263,7 @@ try {
$ticketId = (int)$data['ticket_id'];
// Initialize controller
$controller = new ApiTicketController($conn, $userId, $isAdmin);
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
// Update ticket
$result = $controller->update($ticketId, $data);
@@ -201,7 +271,17 @@ try {
// Discard any output that might have been generated
ob_end_clean();
// Invalidate stats cache on successful ticket update
if (!empty($result['success'])) {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
}
// Return response
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
}
header('Content-Type: application/json');
echo json_encode($result);
+42 -10
View File
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
@@ -40,13 +41,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (9-digit number)
if (!preg_match('/^\d{9}$/', $ticketId)) {
// Validate ticket ID format (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
try {
$attachmentModel = new AttachmentModel();
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
$attachmentModel = new AttachmentModel($conn);
$attachments = $attachmentModel->getAttachments($ticketId);
// Add formatted file size and icon to each attachment
@@ -78,11 +86,19 @@ if (empty($ticketId)) {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (9-digit number)
if (!preg_match('/^\d{9}$/', $ticketId)) {
// Validate ticket ID format (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
// Verify user can access the ticket before accepting upload
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
// Check if file was uploaded
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
ResponseHelper::error('No file uploaded');
@@ -135,10 +151,26 @@ if (!is_dir($ticketDir)) {
}
}
// Generate unique filename
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : '');
// Derive extension from validated MIME type (never from user-supplied filename)
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
$mimeToExt = [
'image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp',
'application/pdf' => 'pdf',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/zip' => 'zip',
'application/x-7z-compressed' => '7z',
'application/x-tar' => 'tar',
'application/gzip' => 'gz',
'application/json' => 'json',
'application/xml' => 'xml',
];
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
$targetPath = $ticketDir . '/' . $uniqueFilename;
// Move uploaded file
@@ -155,7 +187,7 @@ if (empty($originalFilename)) {
// Save to database
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment(
$ticketId,
$uniqueFilename,
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* User Avatar API
*
* Serves profile pictures fetched from lldap via LDAP.
* Caches images locally to avoid repeated LDAP queries.
*
* GET /api/user_avatar.php?user_id=123
* Returns the user's JPEG avatar (from cache or LDAP).
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Must be authenticated
if (!isset($_SESSION['user']['user_id'])) {
http_response_code(401);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
exit;
}
$cfg = $GLOBALS['config'];
// Validate user_id parameter
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
if ($userId <= 0) {
http_response_code(400);
exit;
}
// Ensure LDAP is enabled and extension is loaded
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
http_response_code(404);
exit;
}
// Ensure avatar cache directory exists
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
// Serve from cache if fresh
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: cache');
readfile($cacheFile);
exit;
}
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
http_response_code(404);
exit;
}
// Look up username from DB
try {
$conn = Database::getConnection();
$stmt = $conn->prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1");
$stmt->bind_param('i', $userId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
if (!$row || empty($row['username'])) {
http_response_code(404);
exit;
}
$username = $row['username'];
} catch (Exception $e) {
error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage());
http_response_code(500);
exit;
}
// Query lldap via LDAP
$ldapHost = $cfg['LDAP_HOST'];
$ldapPort = $cfg['LDAP_PORT'];
$bindDn = $cfg['LDAP_BIND_DN'];
$bindPw = $cfg['LDAP_BIND_PW'];
$userBase = $cfg['LDAP_USER_BASE'];
// Escape username for LDAP filter (RFC 4515)
$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
$filter = "(uid=$safeUsername)";
$avatarData = null;
try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
if (!$ldap) {
throw new RuntimeException("ldap_connect failed");
}
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3);
ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3);
if (!@ldap_bind($ldap, $bindDn, $bindPw)) {
throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap));
}
$search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3);
if (!$search) {
throw new RuntimeException("LDAP search failed: " . ldap_error($ldap));
}
$entries = ldap_get_entries($ldap, $search);
if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) {
// ldap_get_entries() returns the attribute value as raw binary.
$avatarData = $entries[0]['avatar'][0];
}
ldap_unbind($ldap);
} catch (Exception $e) {
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
// Fall through to 404
}
if ($avatarData === null || strlen($avatarData) < 100) {
// Write sentinel so we don't hammer LDAP for users without avatars
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Validate it's actually a JPEG (magic bytes FF D8 FF)
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
error_log("user_avatar: non-JPEG data for username=$username");
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Cache to disk
file_put_contents($cacheFile, $avatarData);
// Remove stale sentinel if present
if (file_exists($noAvatarSentinel)) {
unlink($noAvatarSentinel);
}
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: ldap');
echo $avatarData;
+16 -14
View File
@@ -13,10 +13,10 @@ $prefsModel = new UserPreferencesModel($conn);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try {
$prefs = $prefsModel->getUserPreferences($userId);
echo json_encode(['success' => true, 'preferences' => $prefs]);
apiRespond(['success' => true, 'preferences' => $prefs]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
apiRespond(['success' => false, 'error' => 'Failed to fetch preferences']);
}
exit;
}
@@ -30,9 +30,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'rows_per_page',
'default_status_filters',
'table_density',
'timezone',
'notifications_enabled',
'sound_effects',
'toast_duration'
'toast_duration',
'notif_last_seen',
];
// Support batch save: { preferences: { key: value, ... } }
@@ -43,13 +45,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($key, $validKeys)) continue;
$prefsModel->setPreference($userId, $key, $value);
if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
}
}
echo json_encode(['success' => true]);
apiRespond(['success' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preferences']);
apiRespond(['success' => false, 'error' => 'Failed to save preferences']);
}
exit;
}
@@ -57,7 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Single preference: { key, value }
if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
apiRespond(['success' => false, 'error' => 'Missing key or value']);
exit;
}
@@ -66,7 +68,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($key, $validKeys)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
apiRespond(['success' => false, 'error' => 'Invalid preference key']);
exit;
}
@@ -78,10 +80,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
}
echo json_encode(['success' => $success]);
apiRespond(['success' => $success]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
apiRespond(['success' => false, 'error' => 'Failed to save preference']);
}
exit;
}
@@ -92,20 +94,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['key'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key']);
apiRespond(['success' => false, 'error' => 'Missing key']);
exit;
}
try {
$success = $prefsModel->deletePreference($userId, $data['key']);
echo json_encode(['success' => $success]);
apiRespond(['success' => $success]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
apiRespond(['success' => false, 'error' => 'Failed to delete preference']);
}
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* Watch / Unwatch Ticket API
*
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$ticketId = isset($_GET['ticket_id'])
? (int)$_GET['ticket_id']
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = (int)($data['ticket_id'] ?? 0);
$action = $data['action'] ?? '';
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if ($action === 'watch') {
$stmt = $conn->prepare(
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
} else {
$stmt = $conn->prepare(
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
}
// Return updated state
$countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
);
$countStmt->bind_param("i", $ticketId);
$countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close();
apiRespond([
'success' => true,
'watching' => $action === 'watch',
'watcher_count' => $count,
]);
}
// GET — return current watch state for this user
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
exit;
}
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$watchingStmt->bind_param("ii", $ticketId, $userId);
$watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close();
// Fetch watcher list (up to 6) with display names for avatar group
$watchersStmt = $conn->prepare(
"SELECT u.user_id, COALESCE(u.display_name, u.username) AS display_name
FROM ticket_watchers tw
JOIN users u ON tw.user_id = u.user_id
WHERE tw.ticket_id = ?
ORDER BY tw.created_at ASC
LIMIT 6"
);
$watchersStmt->bind_param("i", $ticketId);
$watchersStmt->execute();
$watchersResult = $watchersStmt->get_result();
$watchers = [];
while ($row = $watchersResult->fetch_assoc()) {
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
}
$watchersStmt->close();
$count = count($watchers);
echo json_encode([
'success' => true,
'watching' => $watching,
'watcher_count' => $count,
'watchers' => $watchers,
]);
+5664
View File
File diff suppressed because it is too large Load Diff
+342 -5715
View File
File diff suppressed because it is too large Load Diff
+296 -2607
View File
File diff suppressed because it is too large Load Diff
+21 -81
View File
@@ -7,8 +7,7 @@
function openAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lt.modal.open('advancedSearchModal');
loadUsersForSearch();
populateCurrentFilters();
loadSavedFilters();
@@ -17,28 +16,13 @@ function openAdvancedSearch() {
// Close advanced search modal
function closeAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
lt.modal.close('advancedSearchModal');
}
// Load users for dropdown
async function loadUsersForSearch() {
try {
const response = await fetch('/api/get_users.php', {
credentials: 'same-origin'
});
const data = await response.json();
const data = await lt.api.get('/api/get_users.php');
if (data.success && data.users) {
const createdBySelect = document.getElementById('adv-created-by');
@@ -68,7 +52,7 @@ async function loadUsersForSearch() {
});
}
} catch (error) {
console.error('Error loading users:', error);
lt.toast.error('Error loading users');
}
}
@@ -156,37 +140,21 @@ async function saveCurrentFilter() {
'My Filter',
async (filterName) => {
if (!filterName || filterName.trim() === '') {
toast.warning('Filter name cannot be empty', 2000);
lt.toast.warning('Filter name cannot be empty', 2000);
return;
}
const filterCriteria = getCurrentFilterCriteria();
try {
const response = await fetch('/api/saved_filters.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
await lt.api.post('/api/saved_filters.php', {
filter_name: filterName.trim(),
filter_criteria: filterCriteria
})
});
const result = await response.json();
if (result.success) {
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
loadSavedFilters();
} else {
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
}
} catch (error) {
console.error('Error saving filter:', error);
toast.error('Error saving filter', 4000);
lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
}
}
);
@@ -213,7 +181,7 @@ function getCurrentFilterCriteria() {
const statusSelect = document.getElementById('adv-status');
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
if (selectedStatuses.length > 0) criteria.status = selectedStatuses; // keep as array — pill handler uses .join(',')
const priorityMin = document.getElementById('adv-priority-min').value;
if (priorityMin) criteria.priority_min = priorityMin;
@@ -233,16 +201,12 @@ function getCurrentFilterCriteria() {
// Load saved filters
async function loadSavedFilters() {
try {
const response = await fetch('/api/saved_filters.php', {
credentials: 'same-origin'
});
const data = await response.json();
const data = await lt.api.get('/api/saved_filters.php');
if (data.success && data.filters) {
populateSavedFiltersDropdown(data.filters);
}
} catch (error) {
console.error('Error loading saved filters:', error);
lt.toast.error('Error loading saved filters');
}
}
@@ -277,7 +241,7 @@ function loadSavedFilter() {
const criteria = JSON.parse(selectedOption.dataset.criteria);
applySavedFilterCriteria(criteria);
} catch (error) {
console.error('Error loading filter:', error);
lt.toast.error('Error loading filter');
}
}
@@ -292,9 +256,11 @@ function applySavedFilterCriteria(criteria) {
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
// Status
// Status — criteria.status may be an array (new saves) or a comma-joined string (old saves)
const statusSelect = document.getElementById('adv-status');
const statuses = criteria.status ? criteria.status.split(',') : [];
const statuses = criteria.status
? (Array.isArray(criteria.status) ? criteria.status : criteria.status.split(','))
: [];
Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value);
});
@@ -314,9 +280,7 @@ async function deleteSavedFilter() {
const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || selectedOption.value === '') {
if (typeof toast !== 'undefined') {
toast.error('Please select a filter to delete');
}
lt.toast.error('Please select a filter to delete');
return;
}
@@ -329,45 +293,21 @@ async function deleteSavedFilter() {
'error',
async () => {
try {
const response = await fetch('/api/saved_filters.php', {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ filter_id: filterId })
});
const result = await response.json();
if (result.success) {
toast.success('Filter deleted successfully', 3000);
await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
lt.toast.success('Filter deleted successfully', 3000);
loadSavedFilters();
resetAdvancedSearch();
} else {
toast.error('Failed to delete filter', 4000);
}
} catch (error) {
console.error('Error deleting filter:', error);
toast.error('Error deleting filter', 4000);
lt.toast.error('Error deleting filter', 4000);
}
}
);
}
// Keyboard shortcut (Ctrl+Shift+F)
// Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
openAdvancedSearch();
}
// ESC to close
if (e.key === 'Escape') {
const modal = document.getElementById('advancedSearchModal');
if (modal && modal.style.display === 'flex') {
closeAdvancedSearch();
}
}
});
+2 -16
View File
@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
const container = document.querySelector(containerSelector);
if (!container || !banner) {
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
return;
}
// Create pre element for ASCII art
const pre = document.createElement('pre');
pre.className = 'ascii-banner';
pre.style.margin = '0';
pre.style.fontFamily = 'var(--font-mono)';
pre.style.color = 'var(--terminal-green)';
if (addGlow) {
pre.style.textShadow = 'var(--glow-green)';
}
pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
pre.style.fontSize = getBannerFontSize(bannerId);
pre.style.lineHeight = '1.2';
pre.style.whiteSpace = 'pre';
pre.style.overflow = 'visible';
pre.style.textAlign = 'center';
container.appendChild(pre);
@@ -179,8 +166,7 @@ function animatedWelcome(containerSelector) {
banner.addEventListener('bannerComplete', () => {
const cursor = document.createElement('span');
cursor.textContent = '█';
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
cursor.style.marginLeft = '5px';
cursor.className = 'ascii-banner-cursor';
banner.appendChild(cursor);
});
}
+2947
View File
File diff suppressed because it is too large Load Diff
+613 -958
View File
File diff suppressed because it is too large Load Diff
+84 -223
View File
@@ -1,173 +1,9 @@
/**
* Keyboard shortcuts for power users
* Keyboard shortcuts for power users.
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
*/
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('keydown', function(e) {
// ESC: Close modals, cancel edit mode, blur inputs
if (e.key === 'Escape') {
// Close any open modals first
const openModals = document.querySelectorAll('.modal-overlay');
let closedModal = false;
openModals.forEach(modal => {
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
modal.remove();
document.body.classList.remove('modal-open');
closedModal = true;
}
});
// Close settings modal if open
const settingsModal = document.getElementById('settingsModal');
if (settingsModal && settingsModal.style.display !== 'none') {
settingsModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// Close advanced search modal if open
const searchModal = document.getElementById('advancedSearchModal');
if (searchModal && searchModal.style.display !== 'none') {
searchModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// If we closed a modal, stop here
if (closedModal) {
e.preventDefault();
return;
}
// Blur any focused input
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
e.target.blur();
}
// Cancel edit mode on ticket pages
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
window.location.reload();
}
return;
}
// Skip other shortcuts if user is typing in an input/textarea
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
return;
}
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
}
// Ctrl/Cmd + S: Save ticket (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
toast.success('Saving ticket...');
}
}
// Ctrl/Cmd + K: Focus search (on dashboard)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchBox = document.querySelector('.search-box');
if (searchBox) {
searchBox.focus();
searchBox.select();
}
}
// ? : Show keyboard shortcuts help (requires Shift on most keyboards)
if (e.key === '?') {
e.preventDefault();
showKeyboardHelp();
}
// J: Move to next row in table (Gmail-style)
if (e.key === 'j') {
e.preventDefault();
navigateTableRow('next');
}
// K: Move to previous row in table (Gmail-style)
if (e.key === 'k') {
e.preventDefault();
navigateTableRow('prev');
}
// Enter: Open selected ticket
if (e.key === 'Enter') {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
e.preventDefault();
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) {
window.location.href = ticketLink.href;
}
}
}
// N: Create new ticket (on dashboard)
if (e.key === 'n') {
e.preventDefault();
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) {
window.location.href = newTicketBtn.href;
}
}
// C: Focus comment textarea (on ticket page)
if (e.key === 'c') {
const commentBox = document.getElementById('newComment');
if (commentBox) {
e.preventDefault();
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// G then D: Go to Dashboard (vim-style)
if (e.key === 'g') {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
}
if (e.key === 'd' && window._pendingG) {
e.preventDefault();
window._pendingG = false;
window.location.href = '/';
}
// 1-4: Quick status change on ticket page
if (['1', '2', '3', '4'].includes(e.key)) {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.modal-overlay')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[e.key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
e.preventDefault();
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change'));
}
}
}
});
});
// Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1;
@@ -175,7 +11,6 @@ function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr');
if (rows.length === 0) return;
// Remove current selection
rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') {
@@ -184,7 +19,6 @@ function navigateTableRow(direction) {
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
}
// Add selection to new row
const selectedRow = rows[currentSelectedRowIndex];
if (selectedRow) {
selectedRow.classList.add('keyboard-selected');
@@ -192,60 +26,87 @@ function navigateTableRow(direction) {
}
}
function showKeyboardHelp() {
// Check if help is already showing
if (document.getElementById('keyboardHelpModal')) {
return;
document.addEventListener('DOMContentLoaded', function() {
if (!window.lt) return;
// Ctrl+E: Toggle edit mode (ticket pages)
lt.keys.on('ctrl+e', function() {
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
const modal = document.createElement('div');
modal.id = 'keyboardHelpModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
<div class="ascii-frame">
<div class="ascii-content">
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
<div class="modal-body" style="padding: 0;">
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
</table>
</div>
<div class="modal-footer" style="margin-top: 1rem;">
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Add event listener for the close button
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
modal.remove();
});
// Ctrl+S: Save ticket (ticket pages)
lt.keys.on('ctrl+s', function() {
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
lt.toast.success('Saving ticket...');
}
});
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
lt.keys.on('?', function() {
if (window.lt) lt.modal.open('lt-keys-help');
});
// J: Next row
lt.keys.on('j', () => navigateTableRow('next'));
// K: Previous row
lt.keys.on('k', () => navigateTableRow('prev'));
// Enter: Open selected ticket
lt.keys.on('enter', function() {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) window.location.href = ticketLink.href;
}
});
// N: New ticket
lt.keys.on('n', function() {
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) window.location.href = newTicketBtn.href;
});
// C: Focus comment box
lt.keys.on('c', function() {
const commentBox = document.getElementById('newComment');
if (commentBox) {
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
// G then D: Go to Dashboard (vim-style)
lt.keys.on('g', function() {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
});
lt.keys.on('d', function() {
if (window._pendingG) {
window._pendingG = false;
window.location.href = '/';
}
});
// 1-4: Quick status change on ticket page
['1', '2', '3', '4'].forEach(key => {
lt.keys.on(key, function() {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
});
});
+1 -1
View File
@@ -364,7 +364,7 @@ function createEditorToolbar(textareaId, containerId) {
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">🔗</button>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
`;
// Add event delegation for toolbar buttons
+11 -47
View File
@@ -8,16 +8,13 @@ let userPreferences = {};
// Load preferences on page load
async function loadUserPreferences() {
try {
const response = await fetch('/api/user_preferences.php', {
credentials: 'same-origin'
});
const data = await response.json();
const data = await lt.api.get('/api/user_preferences.php');
if (data.success) {
userPreferences = data.preferences;
applyPreferences();
}
} catch (error) {
console.error('Error loading preferences:', error);
lt.toast.error('Error loading preferences');
}
}
@@ -94,34 +91,12 @@ async function saveSettings() {
};
try {
// Batch save all preferences in one request
const response = await fetch('/api/user_preferences.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ preferences: prefs })
});
const result = await response.json();
if (!result.success) {
throw new Error('Failed to save preferences');
}
if (typeof toast !== 'undefined') {
toast.success('Preferences saved successfully!');
}
await lt.api.post('/api/user_preferences.php', { preferences: prefs });
lt.toast.success('Preferences saved successfully!');
closeSettingsModal();
// Reload page to apply new preferences
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
if (typeof toast !== 'undefined') {
toast.error('Error saving preferences');
}
console.error('Error saving preferences:', error);
lt.toast.error('Error saving preferences');
}
}
@@ -129,24 +104,18 @@ async function saveSettings() {
function openSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lt.modal.open('settingsModal');
loadUserPreferences();
}
}
function closeSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
lt.modal.close('settingsModal');
}
// Close modal when clicking on backdrop (outside the settings content)
function closeOnBackdropClick(event) {
const modal = document.getElementById('settingsModal');
// Only close if clicking directly on the modal backdrop, not on content
if (event.target === modal) {
closeSettingsModal();
}
@@ -158,15 +127,10 @@ document.addEventListener('keydown', (e) => {
e.preventDefault();
openSettingsModal();
}
// ESC to close modal
if (e.key === 'Escape') {
const modal = document.getElementById('settingsModal');
if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
closeSettingsModal();
}
}
// ESC is handled globally by lt.keys.initDefaults()
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', loadUserPreferences);
document.addEventListener('DOMContentLoaded', function() {
if (window.lt) loadUserPreferences();
});
+432 -429
View File
File diff suppressed because it is too large Load Diff
+15 -87
View File
@@ -1,94 +1,22 @@
/**
* Terminal-style toast notification system with queuing
* Deprecated: use lt.toast.* directly (from web_template/base.js).
* This shim maintains backwards compatibility while callers are migrated.
*/
// Toast queue management
let toastQueue = [];
let currentToast = null;
function showToast(message, type = 'info', duration = 3000) {
// Queue if a toast is already showing
if (currentToast) {
toastQueue.push({ message, type, duration });
return;
// showToast() shim — used by inline view scripts
function showToast(message, type = 'info', duration = 3500) {
switch (type) {
case 'success': lt.toast.success(message, duration); break;
case 'error': lt.toast.error(message, duration); break;
case 'warning': lt.toast.warning(message, duration); break;
default: lt.toast.info(message, duration); break;
}
}
displayToast(message, type, duration);
}
function displayToast(message, type, duration) {
// Create toast element
const toast = document.createElement('div');
toast.className = `terminal-toast toast-${type}`;
currentToast = toast;
// Icon based on type
const icons = {
success: '✓',
error: '✗',
info: '',
warning: '⚠'
};
const iconSpan = document.createElement('span');
iconSpan.className = 'toast-icon';
iconSpan.textContent = `[${icons[type] || ''}]`;
const msgSpan = document.createElement('span');
msgSpan.className = 'toast-message';
msgSpan.textContent = message;
const closeSpan = document.createElement('span');
closeSpan.className = 'toast-close';
closeSpan.style.cssText = 'margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;';
closeSpan.textContent = '[×]';
toast.appendChild(iconSpan);
toast.appendChild(msgSpan);
toast.appendChild(closeSpan);
// Add to document
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Manual dismiss handler
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => dismissToast(toast));
// Auto-remove after duration
const timeoutId = setTimeout(() => {
dismissToast(toast);
}, duration);
// Store timeout ID for manual dismiss
toast.timeoutId = timeoutId;
}
function dismissToast(toast) {
// Clear auto-dismiss timeout
if (toast.timeoutId) {
clearTimeout(toast.timeoutId);
}
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
currentToast = null;
// Show next toast in queue
if (toastQueue.length > 0) {
const next = toastQueue.shift();
displayToast(next.message, next.type, next.duration);
}
}, 300);
}
// Convenience functions
// window.toast.* shim — used by JS files
window.toast = {
success: (msg, duration) => showToast(msg, 'success', duration),
error: (msg, duration) => showToast(msg, 'error', duration),
info: (msg, duration) => showToast(msg, 'info', duration),
warning: (msg, duration) => showToast(msg, 'warning', duration)
success: (msg, dur) => lt.toast.success(msg, dur),
error: (msg, dur) => lt.toast.error(msg, dur),
warning: (msg, dur) => lt.toast.warning(msg, dur),
info: (msg, dur) => lt.toast.info(msg, dur),
};
+45 -4
View File
@@ -1,8 +1,6 @@
// XSS prevention helper
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return lt.escHtml(text);
}
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
@@ -12,3 +10,46 @@ function getTicketIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('id');
}
/**
* Show a terminal-style confirmation modal using the lt.modal system.
* @param {string} title - Modal title
* @param {string} message - Confirmation message
* @param {string} type - 'warning' | 'error' | 'info'
* @param {Function} onConfirm - Called when user confirms
* @param {Function|null} onCancel - Called when user cancels
*/
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
const color = colors[type] || colors.warning;
const icon = icons[type] || icons.warning;
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message).replace(/\n/g, '<br>');
document.body.insertAdjacentHTML('beforeend', `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color:${color};">
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body lt-text-center">
<p>${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
}
+48 -2
View File
@@ -20,6 +20,31 @@ 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 — auto-computed from key asset mtimes so
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
'ASSET_VERSION' => (function() use ($envVars) {
if (!empty($envVars['ASSET_VERSION'])) return $envVars['ASSET_VERSION'];
$files = [
__DIR__ . '/../assets/css/base.css',
__DIR__ . '/../assets/css/dashboard.css',
__DIR__ . '/../assets/css/ticket.css',
__DIR__ . '/../assets/js/base.js',
__DIR__ . '/../assets/js/dashboard.js',
__DIR__ . '/../assets/js/ticket.js',
];
$mtime = 0;
foreach ($files as $f) { if (file_exists($f)) $mtime = max($mtime, filemtime($f)); }
return $mtime ?: '20260329';
})(),
// Canonical ticket statuses — single source of truth used by views and JS
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
// Database settings
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
'DB_USER' => $envVars['DB_USER'] ?? 'root',
@@ -33,8 +58,18 @@ $GLOBALS['config'] = [
// Matrix webhook (hookshot generic webhook URL)
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org)
// Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
// Set to '1' or 'true' to send a notification when any comment is posted
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Set to '1' or 'true' to send a notification when a ticket is assigned
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Domain settings for external integrations (webhooks, links, etc.)
// Set APP_DOMAIN in .env to override
@@ -87,7 +122,18 @@ $GLOBALS['config'] = [
// Default: America/New_York (EST/EDT)
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null // Will be calculated below
'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups)
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
];
// Set PHP default timezone
+10 -4
View File
@@ -136,13 +136,19 @@ class DashboardController {
// Validate user ID filters
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
if ($createdBy !== null) $filters['created_by'] = $createdBy;
// assigned_to accepts a numeric user ID or the special string 'unassigned'
$assignedToRaw = $_GET['assigned_to'] ?? null;
if ($assignedToRaw === 'unassigned') {
$filters['assigned_to'] = 'unassigned';
} else {
$assignedTo = $this->validateUserId($assignedToRaw);
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
}
// Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
// Get categories and types for filters (single query)
$filterOptions = $this->getCategoriesAndTypes();
@@ -155,7 +161,7 @@ class DashboardController {
$totalPages = $result['pages'];
// Load dashboard statistics
$stats = $this->statsModel->getAllStats();
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
// Load the dashboard view
include 'views/DashboardView.php';
+30 -64
View File
@@ -42,15 +42,17 @@ class TicketController {
return;
}
// Check visibility access
// Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header("HTTP/1.0 403 Forbidden");
echo "Access denied: You do not have permission to view this ticket";
header("HTTP/1.0 404 Not Found");
echo "Ticket not found";
return;
}
// Get comments for this ticket using CommentModel
$comments = $this->commentModel->getCommentsByTicketId($id);
// Load first page of comments; show "load more" if ticket has many
$commentPageSize = 50;
$totalComments = $this->commentModel->getCommentCount((int)$id);
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
// Get timeline for this ticket
$timeline = $this->auditLogModel->getTicketTimeline($id);
@@ -75,6 +77,18 @@ class TicketController {
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_POST['csrf_token'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
$error = "Invalid or expired security token. Please try again.";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
@@ -111,6 +125,17 @@ class TicketController {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Auto-link as duplicate if requested from create form
$linkDupOf = isset($_POST['link_duplicate_of']) ? (int)$_POST['link_duplicate_of'] : 0;
if ($linkDupOf > 0) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_ticket_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("iii", $result['ticket_id'], $linkDupOf, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
@@ -137,64 +162,5 @@ class TicketController {
}
}
public function update($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Add ticket_id to the data
$data['ticket_id'] = $id;
// Validate input data
if (empty($data['title'])) {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Title cannot be empty'
]);
return;
}
// Update ticket with user tracking
// Pass expected_updated_at for optimistic locking if provided
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
// Log ticket update to audit log
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
}
// Return JSON response
header('Content-Type: application/json');
if ($result['success']) {
echo json_encode([
'success' => true,
'status' => $data['status']
]);
} else {
$response = [
'success' => false,
'error' => $result['error'] ?? 'Failed to update ticket'
];
if (!empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
echo json_encode($response);
}
} else {
// For direct access, redirect to view
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/$id");
exit;
}
}
}
?>
+231 -88
View File
@@ -84,150 +84,284 @@ $conn->query($createTableSQL);
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
// Validate required fields before any processing
if (!is_array($data) || empty($data['title'])) {
// Try URL-encoded fallback
if (empty($data['title'])) {
parse_str($rawInput, $urlData);
if (!empty($urlData['title'])) {
$data = $urlData;
}
}
if (!is_array($data) || empty($data['title'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'title is required']);
exit;
}
}
// Generate hash from stable components
function generateTicketHash($data) {
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
$isDriveTicket = !empty($deviceMatches);
$title = (string)($data['title'] ?? '');
// Extract hostname from title [hostname][tags]...
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
// Prefer explicit serial from payload; fall back to extracting device path from title
// for backwards compatibility with older hwmonDaemon versions.
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
? (string)$data['serial']
: null;
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
// Extract first bracketed tag as hostname/source
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
$hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values)
// Detect issue category and optional sub-type
$issueCategory = '';
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash)
$issueSubtype = '';
$isClusterWide = false;
if (stripos($data['title'], 'SMART issues') !== false) {
if (stripos($title, 'SMART issues') !== false) {
$issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
$issueCategory = 'storage';
} elseif (stripos($data['title'], 'memory') !== false) {
// Include the LXC container ID so each container gets its own ticket
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
$issueSubtype = 'lxc_' . $lxcMatch[1];
}
} elseif (stripos($title, 'memory') !== false) {
$issueCategory = 'memory';
} elseif (stripos($data['title'], 'cpu') !== false) {
} elseif (stripos($title, 'cpu') !== false) {
$issueCategory = 'cpu';
} elseif (stripos($data['title'], 'network') !== false) {
} elseif (stripos($title, 'network') !== false) {
$issueCategory = 'network';
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
$issueCategory = 'ceph';
// Ceph cluster-wide issues should deduplicate across all nodes
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
if (stripos($data['title'], '[cluster-wide]') !== false ||
stripos($data['title'], 'HEALTH_ERR') !== false ||
stripos($data['title'], 'HEALTH_WARN') !== false ||
stripos($data['title'], 'cluster usage') !== false) {
if (stripos($title, '[cluster-wide]') !== false ||
stripos($title, 'HEALTH_ERR') !== false ||
stripos($title, 'HEALTH_WARN') !== false ||
stripos($title, 'cluster usage') !== false) {
$isClusterWide = true;
}
// Normalize the specific Ceph warning type so different warnings get distinct tickets
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
$issueSubtype = 'bluestore_slow';
} elseif (stripos($title, 'clock skew') !== false) {
$issueSubtype = 'clock_skew';
} elseif (stripos($title, 'cluster usage') !== false) {
$issueSubtype = 'usage';
} elseif (stripos($title, 'OSD down') !== false) {
$issueSubtype = 'osd_down';
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
$issueSubtype = 'health_err';
}
}
// Build stable components with only static data
// Build stable components
$stableComponents = [
'issue_category' => $issueCategory, // Generic category, not specific errors
'environment_tags' => array_filter(
explode('][', $data['title']),
'issue_category' => $issueCategory,
'issue_subtype' => $issueSubtype,
'environment_tags' => array_values(array_filter(
explode('][', $title),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
)
)),
];
// Only include hostname for non-cluster-wide issues
// This allows cluster-wide issues to deduplicate across all nodes
// Include hostname for node-specific issues
if (!$isClusterWide) {
$stableComponents['hostname'] = $hostname;
}
// Only include device info for drive-specific tickets
// Include drive identifier for drive-specific tickets.
// Use serial when available (stable across reboots/reshuffles); fall back to
// device path for tickets created before serial was added to the payload.
if ($isDriveTicket) {
$stableComponents['device'] = $deviceMatches[0];
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
}
// Sort arrays for consistent hashing
sort($stableComponents['environment_tags']);
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
}
// Check for duplicate tickets
// Shared ticket data
$title = (string)($data['title'] ?? '');
$description = (string)($data['description'] ?? '');
$status = (string)($data['status'] ?? 'Open');
$priority = $data['priority'] ?? '4';
$category = (string)($data['category'] ?? 'General');
$type = (string)($data['type'] ?? 'Issue');
$ticketHash = generateTicketHash($data);
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
$checkStmt = $conn->prepare($checkDuplicateSQL);
$auditLog = new AuditLogModel($conn);
// Look up any existing ticket with this hash (open OR closed)
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
$checkStmt->bind_param("s", $ticketHash);
$checkStmt->execute();
$result = $checkStmt->get_result();
$existing = $checkStmt->get_result()->fetch_assoc();
$checkStmt->close();
if ($result->num_rows > 0) {
$existingTicket = $result->fetch_assoc();
if ($existing) {
$existingId = $existing['ticket_id'];
$existingStatus = $existing['status'];
$existingTitle = $existing['title'];
$existingPriority = (int)$existing['priority'];
$newPriority = (int)$priority;
if ($existingStatus !== 'Closed') {
// Ticket is still active — update title and escalate priority if the new
// report is more severe (lower number = higher severity).
$changes = [];
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
$bindTypes = "i";
$bindVals = [$userId];
if ($title !== $existingTitle) {
$updateSql .= ", title = ?";
$bindTypes .= "s";
$bindVals[] = $title;
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
}
if ($newPriority < $existingPriority) {
$updateSql .= ", priority = ?";
$bindTypes .= "i";
$bindVals[] = $newPriority;
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
}
if (!empty($changes)) {
$updateSql .= " WHERE ticket_id = ?";
$bindTypes .= "s";
$bindVals[] = $existingId;
$updStmt = $conn->prepare($updateSql);
$updStmt->bind_param($bindTypes, ...$bindVals);
$updStmt->execute();
$updStmt->close();
// Comment summarising what changed
$changeLines = [];
if (isset($changes['title'])) {
$changeLines[] = "- **Title updated** to reflect current issue";
}
if (isset($changes['priority'])) {
$changeLines[] = "- **Priority escalated** from P{$changes['priority']['from']} to P{$changes['priority']['to']}";
}
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
implode("\n", $changeLines) . "\n\nLatest report:\n\n" . $description;
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
$commentStmt->execute();
$commentStmt->close();
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
$changes,
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
));
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $newPriority < $existingPriority ? $newPriority : $existingPriority,
'category' => $category,
'type' => $type,
'status' => $existingStatus,
], 'automated');
}
$conn->close();
echo json_encode([
'success' => false,
'error' => 'Duplicate ticket',
'existing_ticket_id' => $existingTicket['ticket_id']
'success' => true,
'ticket_id' => $existingId,
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
'action' => empty($changes) ? 'deduplicated' : 'updated',
'changes' => $changes,
]);
exit;
}
// Force JSON content type for all incoming requests
header('Content-Type: application/json');
// Ticket was closed — reopen it and add a recurrence comment
$reopenStmt = $conn->prepare(
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
);
$reopenStmt->bind_param("is", $userId, $existingId);
$reopenStmt->execute();
$reopenStmt->close();
if (!$data) {
// Try parsing as URL-encoded data
parse_str($rawInput, $data);
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"New report received from hwmonDaemon:\n\n" . $description;
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
$commentStmt->execute();
$commentStmt->close();
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
], 'automated');
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
]);
exit;
}
// Generate ticket ID (9-digit format with leading zeros)
// No existing ticket — create a new one
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
// Prepare insert query with created_by field
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
// First, store all values in variables
$title = $data['title'];
$description = $data['description'];
$status = $data['status'] ?? 'Open';
$priority = $data['priority'] ?? '4';
$category = $data['category'] ?? 'General';
$type = $data['type'] ?? 'Issue';
// Then use the variables in bind_param
$stmt->bind_param(
"ssssssssi",
$ticket_id,
$title,
$description,
$status,
$priority,
$category,
$type,
$ticketHash,
$userId
$insertStmt = $conn->prepare(
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$insertStmt->bind_param("ssssssssi",
$ticket_id, $title, $description, $status, $priority, $category, $type, $ticketHash, $userId
);
if ($stmt->execute()) {
// Log ticket creation to audit log
$auditLog = new AuditLogModel($conn);
try {
$inserted = $insertStmt->execute();
} catch (mysqli_sql_exception $e) {
$insertStmt->close();
if ($e->getCode() === 1062) {
// Race condition: another node inserted the same hash between our SELECT and INSERT
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
} else {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
$insertStmt->close();
if ($inserted) {
$auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type
'type' => $type,
]);
echo json_encode([
'success' => true,
'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => $conn->error
]);
}
$stmt->close();
$conn->close();
// Matrix webhook notification
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
'title' => $title,
@@ -236,3 +370,12 @@ NotificationHelper::sendTicketNotification($ticket_id, [
'type' => $type,
'status' => $status,
], 'automated');
echo json_encode([
'success' => true,
'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully',
]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
set -e
echo "Deploying tinker_tickets to web server..."
# Deploy to web server
echo "Syncing to web server (10.10.10.45)..."
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
# Set proper permissions on the web server
echo "Setting proper file permissions..."
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
echo "Deployment to web server complete!"
echo "Don't forget to commit and push your changes via VS Code when ready."
+1 -9
View File
@@ -162,13 +162,5 @@ class Database {
return self::getConnection()->insert_id;
}
/**
* Escape a string for use in queries (prefer prepared statements)
*
* @param string $string String to escape
* @return string Escaped string
*/
public static function escape(string $string): string {
return self::getConnection()->real_escape_string($string);
}
// escape() removed — use prepared statements with bind_param() instead
}
+199 -31
View File
@@ -1,41 +1,17 @@
<?php
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class NotificationHelper {
/**
* Send a Matrix webhook notification for a new ticket.
*
* @param string $ticketId Ticket ID (9-digit string)
* @param array $ticketData Ticket fields (title, priority, category, type, status, ...)
* @param string $trigger 'manual' (web UI) or 'automated' (API)
*/
public static function sendTicketNotification($ticketId, $ticketData, $trigger = 'manual') {
// ─── Internal: fire a webhook ─────────────────────────────────────────────
private static function fire(array $payload): void {
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
}
// Parse notify users from config (comma-separated Matrix user IDs)
$notifyRaw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
$notifyUsers = array_values(array_filter(array_map('trim', explode(',', $notifyRaw))));
// Extract hostname from [hostname] prefix in title
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
$payload = [
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => $notifyUsers,
];
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
@@ -48,11 +24,203 @@ class NotificationHelper {
$curlError = curl_error($ch);
curl_close($ch);
$id = $payload['ticket_id'] ?? '?';
if ($curlError) {
error_log("Matrix webhook cURL error for ticket #{$ticketId}: {$curlError}");
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
} elseif ($httpCode < 200 || $httpCode >= 300) {
error_log("Matrix webhook failed for ticket #{$ticketId}. HTTP {$httpCode}: {$response}");
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
}
}
private static function notifyUsers(): array {
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
// ─── Public event methods ─────────────────────────────────────────────────
/**
* New ticket created (manual or automated/API).
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void {
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([
'event' => 'ticket_created',
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => self::notifyUsers(),
]);
}
/**
* Ticket status changed.
*
* @param string|int $ticketId
* @param string $oldStatus
* @param string $newStatus
* @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status
*/
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void {
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => self::notifyUsers(),
]);
}
/**
* New comment posted (non-mention; use sendMentionNotification for @mentions).
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText Plain text (first 200 chars will be sent)
* @param string|null $authorDisplay Display name of commenter
* @param bool $isInternal True if the comment is internal-only
*/
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void {
// Skip if this is an internal-only comment — only the assignee/admin need to know
$notifyUsers = self::notifyUsers();
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'comment_added',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'is_internal' => $isInternal,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
/**
* @mention detected in a comment.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText
* @param string|null $authorDisplay
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
*/
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void {
if (empty($mentionedMatrixIds)) {
return;
}
self::fire([
'event' => 'mention',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $mentionedMatrixIds,
]);
}
/**
* Notify all watchers of a ticket about an update event.
*
* Fetches watchers from the DB, resolves their Matrix IDs via Synapse,
* and fires the appropriate event notification with them in notify_users.
*
* @param \mysqli $conn
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $event One of: status_changed, comment_added, assigned
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void {
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$webhookUrl || !$domain) {
return;
}
// Fetch watcher usernames
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
$usernames = [];
while ($row = $result->fetch_assoc()) {
$usernames[] = $row['username'];
}
if (empty($usernames)) {
return;
}
// Resolve to Matrix IDs — skip users without Synapse accounts
$matrixIds = SynapseHelper::resolveUsernames($usernames);
if (empty($matrixIds)) {
return;
}
// Remove the global notify list duplicates and build payload
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
$payload = array_merge($extraData, [
'event' => $event,
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => array_values($allNotify),
]);
self::fire($payload);
}
/**
* Ticket assigned (or reassigned) to a user.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string|null $assigneeName Display name of new assignee
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
* @param string|null $changedByDisplay
*/
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void {
$notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
$notifyUsers[] = $assigneeMatrix;
}
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'assigned',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'assignee' => $assigneeName,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
}
?>
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* SynapseHelper
*
* Resolves local (SSO) usernames → Matrix user IDs by querying the
* Synapse Admin REST API directly. No caching — every call is live
* so results never go stale.
*
* Required config (.env) keys:
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
*/
class SynapseHelper {
/**
* Resolve a local SSO username to its Matrix user ID.
*
* Uses the Synapse Admin API v2 endpoint:
* GET /_synapse/admin/v2/users/@{username}:{domain}
*
* If the account exists in Synapse the method returns the Matrix ID string.
* If the account does not exist, or if Synapse is unreachable / not configured,
* it returns null silently (notifications are best-effort).
*
* @param string $username Local username (e.g. "jared")
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
*/
public static function resolveUsername(string $username): ?string {
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$baseUrl || !$token || !$domain) {
return null;
}
// Build the Matrix user ID and percent-encode it once for the URL path.
// rawurlencode($username) here would double-encode any special chars when
// the full $matrixId string is encoded again below.
$matrixId = '@' . $username . ':' . $domain;
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
return null;
}
if ($httpCode === 200) {
$data = json_decode($body, true);
// Confirm the response contains the name we expect
if (!empty($data['name'])) {
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
}
}
// 404 = user not found in Synapse; other codes = error
if ($httpCode !== 404) {
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
}
return null;
}
/**
* Resolve multiple usernames to Matrix IDs.
* Returns only those that were successfully confirmed in Synapse.
*
* @param string[] $usernames
* @return string[] Matrix user IDs
*/
public static function resolveUsernames(array $usernames): array {
$ids = [];
foreach ($usernames as $username) {
$id = self::resolveUsername($username);
if ($id !== null) {
$ids[] = $id;
}
}
return $ids;
}
}
?>
+70 -44
View File
@@ -42,8 +42,8 @@ if (!str_starts_with($requestPath, '/api/')) {
require_once 'models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
if ($userTimezone) {
// Override system timezone with user preference
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
// Override system timezone with user preference (validated against known identifiers)
date_default_timezone_set($userTimezone);
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
$now = new DateTime('now', new DateTimeZone($userTimezone));
@@ -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 == '':
@@ -106,6 +115,14 @@ switch (true) {
require_once 'api/get_users.php';
break;
case $requestPath == '/api/get_comments.php':
require_once 'api/get_comments.php';
break;
case $requestPath == '/api/watch_ticket.php':
require_once 'api/watch_ticket.php';
break;
case $requestPath == '/api/assign_ticket.php':
require_once 'api/assign_ticket.php';
break;
@@ -146,13 +163,45 @@ switch (true) {
require_once 'api/check_duplicates.php';
break;
case $requestPath == '/api/custom_fields.php':
require_once 'api/custom_fields.php';
break;
case $requestPath == '/api/saved_filters.php':
require_once 'api/saved_filters.php';
break;
case $requestPath == '/api/audit_log.php':
require_once 'api/audit_log.php';
break;
case $requestPath == '/api/user_preferences.php':
require_once 'api/user_preferences.php';
break;
case $requestPath == '/api/download_attachment.php':
require_once 'api/download_attachment.php';
break;
case $requestPath == '/api/clone_ticket.php':
require_once 'api/clone_ticket.php';
break;
case $requestPath == '/api/health.php':
require_once 'api/health.php';
break;
case $requestPath == '/api/notifications.php':
require_once 'api/notifications.php';
break;
case $requestPath == '/api/user_avatar.php':
require_once 'api/user_avatar.php';
break;
// 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);
@@ -160,11 +209,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);
@@ -172,11 +217,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()) {
@@ -186,11 +227,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()) {
@@ -200,11 +237,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;
@@ -214,7 +247,9 @@ switch (true) {
$params = [];
$types = '';
if (!empty($_GET['action_type'])) {
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
$types .= 's';
@@ -224,15 +259,15 @@ switch (true) {
$whereConditions[] = "al.user_id = ?";
$params[] = (int)$_GET['user_id'];
$types .= 'i';
$filters['user_id'] = $_GET['user_id'];
$filters['user_id'] = (int)$_GET['user_id'];
}
if (!empty($_GET['date_from'])) {
if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $_GET['date_from'];
$types .= 's';
$filters['date_from'] = $_GET['date_from'];
}
if (!empty($_GET['date_to'])) {
if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $_GET['date_to'];
$types .= 's';
@@ -284,11 +319,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();
@@ -296,11 +327,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')),
@@ -377,9 +404,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;
}
+5 -1
View File
@@ -155,7 +155,11 @@ class AuthMiddleware {
}
// Check for admin or employee group membership
$userGroups = array_map('trim', explode(',', strtolower($groups)));
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function($g) { return preg_match('/^[a-z0-9_\-]+$/', $g); }
);
$requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups));
+12
View File
@@ -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
*/
+1 -1
View File
@@ -64,7 +64,7 @@ class RateLimitMiddleware {
$now = time();
// Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = md5($ip . '_' . $type);
$ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data
+1 -1
View File
@@ -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}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
// Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY");
-48
View File
@@ -1,48 +0,0 @@
-- Migration: Add Performance Indexes
-- Run this migration to improve query performance on common operations
-- Single-column indexes for filtering
-- These support the most common WHERE clauses in getAllTickets()
-- Status filtering (very common - used in almost every query)
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
-- Category and type filtering
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
-- Priority filtering
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
-- Date-based filtering and sorting
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
-- User filtering
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
-- Visibility filtering (used in every authenticated query)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
-- Composite indexes for common query patterns
-- These are more efficient than single indexes for combined filters
-- Status + created_at (common sorting with status filter)
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
-- Assigned_to + status (for "my open tickets" queries)
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
-- Visibility + status (visibility filtering with status)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
-- ticket_comments table
-- Optimize comment retrieval by ticket
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
-- Audit log indexes (if audit_log table exists)
-- Optimize audit log queries
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
-19
View File
@@ -1,19 +0,0 @@
-- Migration: Add comment threading support
-- Adds parent_comment_id for reply/thread functionality
-- Add parent_comment_id column for threaded comments
ALTER TABLE ticket_comments
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
-- Add foreign key constraint (self-referencing for thread hierarchy)
ALTER TABLE ticket_comments
ADD CONSTRAINT fk_parent_comment
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
ON DELETE CASCADE;
-- Add index for efficient thread retrieval
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
ALTER TABLE ticket_comments
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;
+7 -23
View File
@@ -3,22 +3,11 @@
* AttachmentModel - Handles ticket file attachments
*/
require_once __DIR__ . '/../config/config.php';
class AttachmentModel {
private $conn;
public function __construct() {
$this->conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($this->conn->connect_error) {
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
}
public function __construct($conn) {
$this->conn = $conn;
}
/**
@@ -32,7 +21,7 @@ class AttachmentModel {
ORDER BY a.uploaded_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
@@ -72,7 +61,7 @@ class AttachmentModel {
VALUES (?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$stmt->bind_param("issisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute();
if ($result) {
@@ -108,7 +97,7 @@ class AttachmentModel {
WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
@@ -124,7 +113,7 @@ class AttachmentModel {
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
@@ -142,7 +131,7 @@ class AttachmentModel {
}
$attachment = $this->getAttachment($attachmentId);
return $attachment && $attachment['uploaded_by'] == $userId;
return $attachment && (int)$attachment['uploaded_by'] === (int)$userId;
}
/**
@@ -204,9 +193,4 @@ class AttachmentModel {
return in_array($mimeType, $allowedTypes);
}
public function __destruct() {
if ($this->conn) {
$this->conn->close();
}
}
}
+8
View File
@@ -19,6 +19,14 @@ class BulkOperationsModel {
* @return int|false Operation ID or false on failure
*/
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
// Validate ticket IDs to prevent injection via implode
$ticketIds = array_values(array_filter(
array_map('strval', $ticketIds),
fn($id) => preg_match('/^[0-9]+$/', $id)
));
if (empty($ticketIds)) {
return false;
}
$ticketIdsStr = implode(',', $ticketIds);
$totalTickets = count($ticketIds);
$parametersJson = $parameters ? json_encode($parameters) : null;
+106 -11
View File
@@ -50,10 +50,35 @@ class CommentModel {
return $users;
}
public function getCommentsByTicketId($ticketId, $threaded = true) {
// Check if threading columns exist
/**
* Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int {
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return (int)($row['total'] ?? 0);
}
/**
* @param int $ticketId
* @param bool $threaded Build nested reply structure (threading)
* @param int $limit Max root-level comments to return (0 = all)
* @param int $offset Root-level comment offset for pagination
*/
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0) {
$hasThreading = $this->hasThreadingSupport();
// When paginating with threading we fetch root comments page first,
// then pull all their replies in a second query.
if ($hasThreading && $threaded && $limit > 0) {
return $this->getThreadedCommentsPaged($ticketId, $limit, $offset);
}
if ($hasThreading) {
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
FROM ticket_comments tc
@@ -70,16 +95,21 @@ class CommentModel {
ORDER BY tc.created_at DESC";
}
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
if ($limit > 0) {
$stmt->bind_param("iii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("i", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
$comments = [];
$commentMap = [];
while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name'];
} else {
@@ -90,8 +120,9 @@ class CommentModel {
$row['thread_depth'] = $row['thread_depth'] ?? 0;
$commentMap[$row['comment_id']] = $row;
}
$stmt->close();
// Build threaded structure if threading is enabled
// Build threaded structure if threading is enabled (no pagination — all loaded)
if ($hasThreading && $threaded) {
$rootComments = [];
foreach ($commentMap as $id => $comment) {
@@ -102,10 +133,73 @@ class CommentModel {
return $rootComments;
}
// Flat list
return array_values($commentMap);
}
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array {
// Page of root comments
$rootSql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ? AND tc.parent_comment_id IS NULL
ORDER BY tc.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($rootSql);
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->execute();
$rootResult = $stmt->get_result();
$stmt->close();
$commentMap = [];
$rootIds = [];
while ($row = $rootResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['parent_comment_id'] = null;
$row['thread_depth'] = 0;
$commentMap[$row['comment_id']] = $row;
$rootIds[] = $row['comment_id'];
}
if (empty($rootIds)) {
return [];
}
// All replies for these root comments (up to 3 levels deep)
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
$replySql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
AND tc.parent_comment_id IN ($placeholders)
AND tc.parent_comment_id IS NOT NULL
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 'i' . str_repeat('i', count($rootIds));
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
while ($row = $replyResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $row['thread_depth'] ?? 1;
$commentMap[$row['comment_id']] = $row;
}
$rootComments = [];
foreach ($rootIds as $rid) {
if (isset($commentMap[$rid])) {
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
}
}
return $rootComments;
}
/**
* Check if threading columns exist
*/
@@ -126,7 +220,8 @@ class CommentModel {
private function buildCommentThread($comment, &$allComments) {
$comment['replies'] = [];
foreach ($allComments as $c) {
if ($c['parent_comment_id'] == $comment['comment_id']) {
if ((int)$c['parent_comment_id'] === (int)$comment['comment_id']
&& isset($allComments[$c['comment_id']])) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
}
}
@@ -239,7 +334,7 @@ class CommentModel {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
}
@@ -285,7 +380,7 @@ class CommentModel {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
}
+1 -1
View File
@@ -128,7 +128,7 @@ class CustomFieldModel {
WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiiiii',
$stmt->bind_param('sssssiiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
+8 -4
View File
@@ -58,7 +58,7 @@ class RecurringTicketModel {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiiisssis',
$stmt->bind_param('ssssiiisssii',
$data['title_template'],
$data['description_template'],
$data['category'],
@@ -176,15 +176,19 @@ class RecurringTicketModel {
break;
case 'weekly':
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
$dayNames = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $dayNames[(int)$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $scheduleTime);
break;
case 'monthly':
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
$day = max(1, min(31, $scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
$next->setDate($next->format('Y'), $next->format('m'), $day);
// Clamp to the last day of the target month (handles Feb, 30-day months, etc.)
$daysInMonth = (int)$next->format('t');
$day = min($day, $daysInMonth);
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
$next->setTime($time->format('H'), $time->format('i'), 0);
break;
+22 -8
View File
@@ -54,6 +54,8 @@ class SavedFiltersModel {
* Save a new filter
*/
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
$this->conn->begin_transaction();
try {
// If this is set as default, unset all other defaults for this user
if ($isDefault) {
$this->clearDefaultFilters($userId);
@@ -71,12 +73,17 @@ class SavedFiltersModel {
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
if ($stmt->execute()) {
return [
'success' => true,
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
];
$filterId = $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName);
$this->conn->commit();
return ['success' => true, 'filter_id' => $filterId];
}
$error = $this->conn->error;
$this->conn->rollback();
return ['success' => false, 'error' => $error];
} catch (Exception $e) {
$this->conn->rollback();
return ['success' => false, 'error' => $e->getMessage()];
}
return ['success' => false, 'error' => $this->conn->error];
}
/**
@@ -126,18 +133,25 @@ class SavedFiltersModel {
* Set a filter as default
*/
public function setDefaultFilter($filterId, $userId) {
// First, clear all defaults
$this->conn->begin_transaction();
try {
$this->clearDefaultFilters($userId);
// Then set this one as default
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $filterId, $userId);
if ($stmt->execute()) {
$this->conn->commit();
return ['success' => true];
}
return ['success' => false, 'error' => $this->conn->error];
$error = $this->conn->error;
$this->conn->rollback();
return ['success' => false, 'error' => $error];
} catch (Exception $e) {
$this->conn->rollback();
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
+57 -144
View File
@@ -7,6 +7,7 @@
*/
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
class StatsModel {
private mysqli $conn;
@@ -21,123 +22,20 @@ class StatsModel {
$this->conn = $conn;
}
/**
* Get count of open tickets
*/
public function getOpenTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of closed tickets
*/
public function getClosedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets grouped by priority
*/
public function getTicketsByPriority(): array {
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data['P' . $row['priority']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by status
*/
public function getTicketsByStatus(): array {
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['status']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by category
*/
public function getTicketsByCategory(): array {
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['category']] = (int)$row['count'];
}
return $data;
}
/**
* Get average resolution time in hours
*/
public function getAverageResolutionTime(): float {
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours
FROM tickets
WHERE status = 'Closed'
AND created_at IS NOT NULL
AND closed_at IS NOT NULL
AND closed_at > created_at";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
}
/**
* Get count of tickets created today
*/
public function getTicketsCreatedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets created this week
*/
public function getTicketsCreatedThisWeek(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets closed today
*/
public function getTicketsClosedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee(int $limit = 5): array {
public function getTicketsByAssignee(int $limit = 8): array {
$sql = "SELECT
u.user_id,
u.display_name,
u.username,
COUNT(t.ticket_id) as ticket_count
COUNT(t.ticket_id) as open_count
FROM tickets t
JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed'
LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL
GROUP BY t.assigned_to
ORDER BY ticket_count DESC
ORDER BY open_count DESC
LIMIT ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
@@ -145,43 +43,31 @@ class StatsModel {
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) {
$name = $row['display_name'] ?: $row['username'];
$data[$name] = (int)$row['ticket_count'];
$data[] = [
'user_id' => (int)$row['user_id'],
'display_name' => $row['display_name'] ?: $row['username'],
'username' => $row['username'],
'open_count' => (int)$row['open_count'],
];
}
$stmt->close();
return $data;
}
/**
* Get unassigned ticket count
*/
public function getUnassignedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get critical (P1) ticket count
*/
public function getCriticalTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get all stats as a single array
* Get all stats as a single array, respecting ticket visibility for the given user.
*
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
* Admins use a shared cache; non-admins use a per-user cache key so confidential
* tickets are not counted in stats for users who cannot access them.
*
* @param array $user Current user array (must include user_id, is_admin, groups)
* @param bool $forceRefresh Force a cache refresh
* @return array All dashboard statistics
*/
public function getAllStats(bool $forceRefresh = false): array {
$cacheKey = 'dashboard_all';
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
$isAdmin = !empty($user['is_admin']);
// Admins share one cache entry; non-admins get a per-user cache entry
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
if ($forceRefresh) {
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
@@ -190,21 +76,28 @@ class StatsModel {
return CacheHelper::remember(
self::CACHE_PREFIX,
$cacheKey,
function() {
return $this->fetchAllStats();
function() use ($user) {
return $this->fetchAllStats($user);
},
self::STATS_CACHE_TTL
);
}
/**
* Fetch all stats from database (uncached)
* Fetch all stats from database (uncached), filtered by the given user's visibility.
*
* Uses consolidated queries to reduce database round-trips from 12 to 4.
* Uses consolidated queries to reduce database round-trips.
*
* @param array $user Current user array
* @return array All dashboard statistics
*/
private function fetchAllStats(): array {
private function fetchAllStats(array $user = []): array {
$ticketModel = new TicketModel($this->conn);
$visFilter = $ticketModel->getVisibilityFilter($user);
$visSQL = $visFilter['sql'];
$visParams = $visFilter['params'];
$visTypes = $visFilter['types'];
// Query 1: Get all simple counts in one query using conditional aggregation
$countsSql = "SELECT
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
@@ -216,23 +109,43 @@ class StatsModel {
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
FROM tickets";
FROM tickets t WHERE ($visSQL)";
if (!empty($visParams)) {
$stmt = $this->conn->prepare($countsSql);
$stmt->bind_param($visTypes, ...$visParams);
$stmt->execute();
$countsResult = $stmt->get_result();
$stmt->close();
} else {
$countsResult = $this->conn->query($countsSql);
}
$counts = $countsResult->fetch_assoc();
// Query 2: Get priority, status, and category breakdowns in one query
$breakdownSql = "SELECT
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY priority
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets GROUP BY status
FROM tickets t WHERE ($visSQL) GROUP BY status
UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY category";
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
if (!empty($visParams)) {
// Need to bind params 3 times (once per UNION branch)
$tripleParams = array_merge($visParams, $visParams, $visParams);
$tripleTypes = $visTypes . $visTypes . $visTypes;
$stmt = $this->conn->prepare($breakdownSql);
$stmt->bind_param($tripleTypes, ...$tripleParams);
$stmt->execute();
$breakdownResult = $stmt->get_result();
$stmt->close();
} else {
$breakdownResult = $this->conn->query($breakdownSql);
}
$byPriority = [];
$byStatus = [];
$byCategory = [];
+1 -1
View File
@@ -87,7 +87,7 @@ class TemplateModel {
default_priority = ?
WHERE template_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssssiii",
$stmt->bind_param("sssssii",
$data['template_name'],
$data['title_template'],
$data['description_template'],
+83 -23
View File
@@ -31,7 +31,7 @@ class TicketModel {
return $result->fetch_assoc();
}
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array {
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array {
// Calculate offset
$offset = ($page - 1) * $limit;
@@ -40,6 +40,16 @@ class TicketModel {
$params = [];
$paramTypes = '';
// Visibility filtering
if ($user !== null) {
$visFilter = $this->getVisibilityFilter($user);
if ($visFilter['sql'] !== '1=1') {
$whereConditions[] = $visFilter['sql'];
$params = array_merge($params, $visFilter['params']);
$paramTypes .= $visFilter['types'];
}
}
// Status filtering
if ($status) {
$statuses = explode(',', $status);
@@ -67,13 +77,24 @@ class TicketModel {
$paramTypes .= str_repeat('s', count($types));
}
// Search Functionality
// Search Functionality — use FULLTEXT when available, fall back to LIKE
if ($search && !empty($search)) {
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)";
if ($this->hasFulltextIndex()) {
// MATCH...AGAINST for indexed full-text search (much faster at scale)
// Strip MySQL boolean mode special chars to prevent parse errors on user input
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'ssss';
} else {
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'sssss';
}
}
// Advanced search filters
// Date range - created_at
@@ -156,53 +177,44 @@ class TicketModel {
// Validate sort direction
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
// Get total count for pagination
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
$countStmt = $this->conn->prepare($countSql);
if (!empty($params)) {
$countStmt->bind_param($paramTypes, ...$params);
}
$countStmt->execute();
$totalResult = $countStmt->get_result();
$totalTickets = $totalResult->fetch_assoc()['total'];
// Get tickets with pagination and creator info
// Single query: use COUNT(*) OVER() window function to get total + page in one pass
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
u_assigned.display_name as assigned_display_name,
COUNT(*) OVER() as _total_count
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause
ORDER BY $sortExpression $sortDirection
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($sql);
// Add limit and offset parameters
$params[] = $limit;
$params[] = $offset;
$paramTypes .= 'ii';
$stmt = $this->conn->prepare($sql);
if (!empty($params)) {
$stmt->bind_param($paramTypes, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
$tickets = [];
$totalTickets = 0;
while ($row = $result->fetch_assoc()) {
$totalTickets = (int)$row['_total_count'];
unset($row['_total_count']);
$tickets[] = $row;
}
$stmt->close();
return [
'tickets' => $tickets,
'total' => $totalTickets,
'pages' => ceil($totalTickets / $limit),
'pages' => $totalTickets > 0 ? ceil($totalTickets / $limit) : 0,
'current_page' => $page
];
}
@@ -422,6 +434,34 @@ class TicketModel {
'ticket_id' => $ticket_id
];
} else {
// Handle duplicate key (errno 1062) caused by race condition between
// the uniqueness SELECT above and this INSERT — regenerate and retry once
if ($this->conn->errno === 1062) {
$stmt->close();
try {
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sssssssiiss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
$status,
$priority,
$category,
$type,
$createdBy,
$assignedTo,
$visibility,
$visibilityGroups
);
if ($stmt->execute()) {
return ['success' => true, 'ticket_id' => $ticket_id];
}
}
return [
'success' => false,
'error' => $this->conn->error
@@ -440,7 +480,7 @@ class TicketModel {
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
$stmt->bind_param(
"sssi",
"issi",
$ticketId,
$username,
$commentData['comment_text'],
@@ -563,7 +603,7 @@ class TicketModel {
// Confidential tickets: only creator, assignee, and admins
if ($visibility === 'confidential') {
$userId = $user['user_id'] ?? null;
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId);
return ((int)$ticket['created_by'] === (int)$userId || (int)$ticket['assigned_to'] === (int)$userId);
}
// Internal tickets: check if user is in any of the allowed groups
@@ -663,4 +703,24 @@ class TicketModel {
$stmt->close();
return $result;
}
/**
* Check whether the FULLTEXT index on tickets(title, description) exists.
* Result is cached for the process lifetime (static).
*/
private function hasFulltextIndex(): bool {
static $result = null;
if ($result !== null) {
return $result;
}
$r = $this->conn->query(
"SELECT COUNT(*) as cnt FROM information_schema.STATISTICS
WHERE table_schema = DATABASE()
AND table_name = 'tickets'
AND index_type = 'FULLTEXT'
AND index_name = 'ft_title_description'"
);
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
return $result;
}
}
+1 -1
View File
@@ -227,7 +227,7 @@ class UserModel {
* @return bool True if user is admin
*/
public function isAdmin(array $user): bool {
return isset($user['is_admin']) && $user['is_admin'] == 1;
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
}
/**
+8
View File
@@ -27,6 +27,10 @@ class WorkflowModel {
WHERE is_active = TRUE";
$result = $this->conn->query($sql);
if (!$result) {
return [];
}
$transitions = [];
while ($row = $result->fetch_assoc()) {
$from = $row['from_status'];
@@ -102,6 +106,10 @@ class WorkflowModel {
ORDER BY status";
$result = $this->conn->query($sql);
if (!$result) {
return [];
}
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];
+282 -252
View File
@@ -1,148 +1,129 @@
<?php
// This file contains the HTML template for creating a new ticket
/**
* CreateTicketView.php New ticket creation form, redesigned for TDS v1.2
* Variables: $templates (array), $allUsers (array), $error (string|null)
*/
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'New Ticket';
$activeNav = 'dashboard';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}", "/assets/css/ticket.css?v={$_v}"];
$pageScripts = [
"/assets/js/keyboard-shortcuts.js?v={$_v}",
];
include __DIR__ . '/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Ticket</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<?php endif; ?>
<!-- Page header -->
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">New Ticket</span>
</div>
</div>
<!-- OUTER FRAME: Create Ticket Form Container -->
<div class="ascii-frame-outer">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<!-- ═══════════════════════════════════════════════════════════
CREATE TICKET FORM
═══════════════════════════════════════════════════════════ -->
<form method="POST"
action="<?= htmlspecialchars($GLOBALS['config']['BASE_URL'], ENT_QUOTES, 'UTF-8') ?>/ticket/create"
class="create-ticket-form"
novalidate>
<!-- SECTION 1: Form Header -->
<div class="ascii-section-header">Create New Ticket</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-header">
<h2>New Ticket Form</h2>
<p style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9rem; margin-top: 0.5rem;">
Complete the form below to create a new ticket
</p>
</div>
</div>
</div>
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="link_duplicate_of" id="linkDuplicateOf" value="">
<?php if (isset($error)): ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ERROR SECTION -->
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="error-message" style="color: var(--priority-1); border: 2px solid var(--priority-1); padding: 1rem; background: rgba(231, 76, 60, 0.1);">
<strong> Error:</strong> <?php echo $error; ?>
<div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
<strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
</div>
<?php endif; ?>
<?php endif ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
<!-- SECTION 2: Template Selection -->
<div class="ascii-section-header">Template Selection</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="templateSelect">Use Template (Optional)</label>
<select id="templateSelect" class="editable" data-action="load-template">
<option value="">-- No Template --</option>
<?php if (isset($templates) && !empty($templates)): ?>
<?php foreach ($templates as $template): ?>
<option value="<?php echo $template['template_id']; ?>">
<?php echo htmlspecialchars($template['template_name']); ?>
<!-- ── SECTION 1: Template ───────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Template (Optional)</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="templateSelect">Use a Template</label>
<select id="templateSelect" class="lt-select" data-action="load-template">
<option value=""> No Template </option>
<?php if (!empty($templates)): ?>
<?php foreach ($templates as $tpl): ?>
<option value="<?= (int)$tpl['template_id'] ?>">
<?= htmlspecialchars($tpl['template_name']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach ?>
<?php endif ?>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
Select a template to auto-fill form fields
</p>
<p class="lt-form-hint">Selecting a template pre-fills the form fields.</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ── SECTION 2: Title ─────────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Title *</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label lt-sr-only" for="title">Ticket Title</label>
<input type="text"
id="title"
name="title"
class="lt-input"
required
autocomplete="off"
placeholder="Enter a clear, concise title for this ticket"
aria-required="true"
aria-describedby="duplicateWarning">
</div>
<!-- SECTION 3: Basic Information -->
<div class="ascii-section-header">Basic Information</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="title">Ticket Title *</label>
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div>
<!-- Duplicate Warning Area -->
<div id="duplicateWarning" style="display: none; margin-top: 1rem; padding: 1rem; border: 2px solid var(--terminal-amber); background: rgba(241, 196, 15, 0.1);">
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;">
Possible Duplicates Found
</div>
<div id="duplicatesList"></div>
<!-- Duplicate warning (shown by JS when similar tickets exist) -->
<div id="duplicateWarning" class="lt-msg lt-msg-warning is-hidden"
role="alert" aria-live="polite" aria-atomic="true">
<strong class="lt-text-amber">Possible Duplicates Found</strong>
<div id="duplicatesList" aria-live="polite"></div>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ── SECTION 3: Metadata ──────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Metadata</div>
<div class="lt-section-body">
<div class="create-ticket-meta-grid">
<!-- SECTION 4: Ticket Metadata -->
<div class="ascii-section-header">Ticket Metadata</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group status-priority-row">
<div class="detail-quarter">
<label for="status">Status</label>
<select id="status" name="status" class="editable">
<div class="lt-form-group">
<label class="lt-label" for="status">Status</label>
<select id="status" name="status" class="lt-select">
<option value="Open" selected>Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="detail-quarter">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="editable">
<option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option>
<option value="3">P3 - Medium Impact</option>
<option value="4" selected>P4 - Low Impact</option>
<div class="lt-form-group">
<label class="lt-label" for="priority">Priority</label>
<select id="priority" name="priority" class="lt-select">
<option value="1">P1 Critical Impact</option>
<option value="2">P2 High Impact</option>
<option value="3">P3 Medium Impact</option>
<option value="4" selected>P4 Low Impact</option>
<option value="5">P5 Minimal Impact</option>
</select>
</div>
<div class="detail-quarter">
<label for="category">Category</label>
<select id="category" name="category" class="editable">
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
@@ -150,206 +131,255 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="General" selected>General</option>
</select>
</div>
<div class="detail-quarter">
<label for="type">Type</label>
<select id="type" name="type" class="editable">
<div class="lt-form-group">
<label class="lt-label" for="type">Type</label>
<select id="type" name="type" class="lt-select">
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue" selected>Issue</option>
<option value="Problem">Problem</option>
</select>
</div>
</div>
</div><!-- /.create-ticket-meta-grid -->
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 4b: Assignment -->
<div class="ascii-section-header">Assignment</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="assigned_to">Assign To (Optional)</label>
<select id="assigned_to" name="assigned_to" class="editable">
<option value="">-- Unassigned --</option>
<?php if (isset($allUsers) && !empty($allUsers)): ?>
<?php foreach ($allUsers as $user): ?>
<option value="<?php echo $user['user_id']; ?>">
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
<!-- ── SECTION 4: Assignment ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Assignment</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value=""> Unassigned </option>
<?php if (!empty($allUsers)): ?>
<?php foreach ($allUsers as $u): ?>
<option value="<?= (int)$u['user_id'] ?>">
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach ?>
<?php endif ?>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
Select a user to assign this ticket to
</p>
<p class="lt-form-hint">Leave blank to create as unassigned.</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 5: Visibility Settings -->
<div class="ascii-section-header">Visibility Settings</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="visibility">Ticket Visibility</label>
<select id="visibility" name="visibility" class="editable" data-action="toggle-visibility-groups">
<option value="public" selected>Public - All authenticated users</option>
<option value="internal">Internal - Specific groups only</option>
<option value="confidential">Confidential - Creator, assignee, admins only</option>
<!-- ── SECTION 5: Visibility ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Visibility</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="visibility">Who can see this ticket?</label>
<select id="visibility" name="visibility" class="lt-select" data-action="toggle-visibility-groups">
<option value="public" selected>Public All authenticated users</option>
<option value="internal">Internal Specific groups only</option>
<option value="confidential">Confidential Creator, assignee, and admins only</option>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
Controls who can view this ticket
</p>
<p id="visibilityHint" class="lt-form-hint">Everyone who is logged in can view this ticket.</p>
</div>
<div id="visibilityGroupsContainer" class="detail-group" style="display: none; margin-top: 1rem;">
<label>Allowed Groups</label>
<div class="visibility-groups-list" style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.5rem;">
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite" aria-describedby="visibilityGroupsHint">
<label class="lt-label lt-text-cyan">Allowed Groups</label>
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php
// Get all available groups
require_once __DIR__ . '/../models/UserModel.php';
$userModel = new UserModel($conn);
$allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group):
?>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
name="visibility_groups[]"
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>">
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
</label>
<?php endforeach; ?>
<?php endforeach ?>
<?php if (empty($allGroups)): ?>
<span style="color: var(--text-muted);">No groups available</span>
<?php endif; ?>
<span class="lt-text-muted lt-text-sm">No groups available</span>
<?php endif ?>
</div>
<p style="color: var(--terminal-amber); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
Select which groups can view this ticket
</p>
<p id="visibilityGroupsHint" class="lt-form-hint lt-form-hint--warn">Select at least one group for Internal visibility.</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Detailed Description -->
<div class="ascii-section-header">Detailed Description</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group full-width">
<label for="description">Description *</label>
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Description *</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-sr-only lt-label" for="description">Description</label>
<textarea id="description"
name="description"
class="lt-input lt-textarea"
rows="16"
required
aria-required="true"
placeholder="Provide a detailed description of the issue, steps to reproduce, expected vs. actual behavior, and any relevant context&hellip;"></textarea>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Form Actions -->
<div class="ascii-section-header">Form Actions</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-footer">
<button type="submit" class="btn primary">Create Ticket</button>
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">Cancel</button>
<!-- ── SECTION 7: Actions ───────────────────────────────── -->
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Actions</div>
<div class="lt-section-body">
<div class="lt-btn-group">
<button type="submit" class="lt-btn lt-btn-primary">CREATE TICKET</button>
<a href="/" class="lt-btn lt-btn-ghost">CANCEL</a>
</div>
</div>
</div>
</form>
</div>
<!-- END OUTER FRAME -->
<script nonce="<?php echo $nonce; ?>">
// Duplicate detection with debounce
let duplicateCheckTimeout = null;
<!-- Page-specific script: duplicate detection + visibility toggle -->
<script nonce="<?= $nonce ?>">
(function () {
'use strict';
// ── Duplicate detection ───────────────────────────────────
var _dupTimer = null;
document.getElementById('title').addEventListener('input', function () {
clearTimeout(duplicateCheckTimeout);
const title = this.value.trim();
clearTimeout(_dupTimer);
var title = this.value.trim();
if (title.length < 5) {
document.getElementById('duplicateWarning').style.display = 'none';
document.getElementById('duplicateWarning').classList.add('is-hidden');
return;
}
// Debounce: wait 500ms after user stops typing
duplicateCheckTimeout = setTimeout(() => {
checkForDuplicates(title);
}, 500);
_dupTimer = setTimeout(function () { checkDuplicates(title); }, 500);
});
function checkForDuplicates(title) {
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(response => response.json())
.then(data => {
const warningDiv = document.getElementById('duplicateWarning');
const listDiv = document.getElementById('duplicatesList');
function checkDuplicates(title) {
if (!window.lt || typeof lt.api === 'undefined') return;
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(function (data) {
var warn = document.getElementById('duplicateWarning');
var list = document.getElementById('duplicatesList');
if (data.success && data.duplicates && data.duplicates.length > 0) {
let html = '<ul style="margin: 0; padding-left: 1.5rem; color: var(--terminal-green);">';
data.duplicates.forEach(dup => {
html += `<li style="margin-bottom: 0.5rem;">
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);">
#${escapeHtml(dup.ticket_id)}
</a>
- ${escapeHtml(dup.title)}
<span style="color: var(--terminal-amber);">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
</li>`;
var ul = document.createElement('ul');
ul.className = 'duplicate-list lt-text-sm';
data.duplicates.forEach(function (dup) {
var li = document.createElement('li');
li.className = 'lt-flex lt-flex-align-center lt-flex-gap-sm lt-mb-xs';
var a = document.createElement('a');
a.href = '/ticket/' + encodeURIComponent(dup.ticket_id);
a.target = '_blank';
a.textContent = '#' + dup.ticket_id;
var dash = document.createTextNode(' \u2014 ' + dup.title + ' ');
var badge = document.createElement('span');
badge.className = 'lt-text-muted';
badge.textContent = '(' + dup.similarity + '% match, ' + dup.status + ')';
var linkBtn = document.createElement('button');
linkBtn.type = 'button';
linkBtn.className = 'lt-btn lt-btn-ghost lt-btn-xs';
linkBtn.dataset.dupId = dup.ticket_id;
linkBtn.textContent = 'Link as duplicate';
linkBtn.title = 'After creating, this ticket will be linked as a duplicate of #' + dup.ticket_id;
linkBtn.addEventListener('click', function () {
var chosen = this.dataset.dupId;
document.getElementById('linkDuplicateOf').value = chosen;
// Update all buttons to show current selection
ul.querySelectorAll('[data-dup-id]').forEach(function (b) {
b.textContent = b.dataset.dupId === chosen ? '\u2713 Will link' : 'Link as duplicate';
b.classList.toggle('lt-btn-primary', b.dataset.dupId === chosen);
});
html += '</ul>';
html += '<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--terminal-green-dim);">Consider checking these tickets before creating a new one.</p>';
listDiv.innerHTML = html;
warningDiv.style.display = 'block';
});
li.appendChild(a);
li.appendChild(dash);
li.appendChild(badge);
li.appendChild(linkBtn);
ul.appendChild(li);
});
var hint = document.createElement('p');
hint.className = 'lt-text-xs lt-text-muted lt-mt-sm';
hint.textContent = 'Check these before creating. Use "Link as duplicate" to auto-link after create.';
list.innerHTML = '';
list.appendChild(ul);
list.appendChild(hint);
warn.classList.remove('is-hidden');
} else {
warningDiv.style.display = 'none';
warn.classList.add('is-hidden');
}
})
.catch(error => {
console.error('Error checking duplicates:', error);
});
.catch(function () { /* silent — duplicate check is non-critical */ });
}
// ── Visibility groups toggle ──────────────────────────────
var visibilityHints = {
'public': 'Everyone who is logged in can view this ticket.',
'internal': 'Only members of the selected groups (plus admins) can view this ticket.',
'confidential': 'Only you, the assignee, and admins can view this ticket.'
};
function toggleVisibilityGroups() {
const visibility = document.getElementById('visibility').value;
const groupsContainer = document.getElementById('visibilityGroupsContainer');
if (visibility === 'internal') {
groupsContainer.style.display = 'block';
var vis = document.getElementById('visibility').value;
var container = document.getElementById('visibilityGroupsContainer');
var hint = document.getElementById('visibilityHint');
if (vis === 'internal') {
container.classList.remove('is-hidden');
} else {
groupsContainer.style.display = 'none';
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
container.classList.add('is-hidden');
container.querySelectorAll('.visibility-group-checkbox').forEach(function (cb) { cb.checked = false; });
}
if (hint) hint.textContent = visibilityHints[vis] || '';
}
// ── Template loader ───────────────────────────────────────
function loadTemplate() {
var tplId = document.getElementById('templateSelect').value;
if (!tplId) return;
// Warn before overwriting content the user has already typed
var existingTitle = (document.getElementById('title').value || '').trim();
var existingDesc = (document.getElementById('description').value || '').trim();
if (existingTitle || existingDesc) {
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
document.getElementById('templateSelect').value = '';
return;
}
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
.then(function (data) {
if (!data.success || !data.template) {
lt.toast.error('Failed to load template.');
return;
}
var t = data.template;
if (t.title) document.getElementById('title').value = t.title;
if (t.description) document.getElementById('description').value = t.description;
if (t.priority) document.getElementById('priority').value = t.priority;
if (t.category) document.getElementById('category').value = t.category;
if (t.type) document.getElementById('type').value = t.type;
// Trigger duplicate check after template fill
document.getElementById('title').dispatchEvent(new Event('input'));
lt.toast.success('Template applied.');
})
.catch(function () { lt.toast.error('Could not load template.'); });
}
// ── Event delegation ──────────────────────────────────────
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
if (action === 'navigate') {
window.location.href = target.dataset.url;
switch (target.getAttribute('data-action')) {
case 'load-template': loadTemplate(); break;
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
}
});
document.addEventListener('change', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
if (action === 'load-template') {
loadTemplate();
} else if (action === 'toggle-visibility-groups') {
toggleVisibilityGroups();
}
});
if (window.lt) lt.keys.initDefaults();
}());
</script>
</body>
</html>
<?php include __DIR__ . '/layout_footer.php'; ?>
+1179 -870
View File
File diff suppressed because it is too large Load Diff
+1169 -679
View File
File diff suppressed because it is too large Load Diff
+142 -205
View File
@@ -1,56 +1,36 @@
<?php
// Admin view for managing API keys
// Receives $apiKeys from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'API Keys';
$activeNav = 'admin-api-keys';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Keys - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: API Keys</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: API Keys</span>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<div class="ascii-section-header">API Key Management</div>
<div class="ascii-content">
<!-- Generate New Key Form -->
<div class="ascii-frame-inner" style="margin-bottom: 1.5rem;">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Generate New API Key</h3>
<form id="generateKeyForm" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Key Name *</label>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline"
style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
<!-- Generate new key -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Generate New API Key</div>
<div class="lt-section-body">
<form id="generateKeyForm" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-flex-align-end">
<div class="lt-form-group" style="flex:2;margin:0">
<label class="lt-label" for="keyName">Key Name *</label>
<input type="text" id="keyName" required class="lt-input" placeholder="e.g., CI/CD Pipeline">
</div>
<div style="min-width: 150px;">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Expires In</label>
<select id="expiresIn" style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" for="expiresIn">Expires In</label>
<select id="expiresIn" class="lt-select">
<option value="">Never</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
@@ -58,204 +38,161 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="365">1 year</option>
</select>
</div>
<div>
<button type="submit" class="btn">Generate Key</button>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form>
</div>
<!-- New Key Display (hidden by default) -->
<div id="newKeyDisplay" class="ascii-frame-inner" style="display: none; margin-bottom: 1.5rem; background: rgba(255, 176, 0, 0.1); border-color: var(--terminal-amber);">
<h3 style="color: var(--terminal-amber); margin-bottom: 0.5rem;">New API Key Generated</h3>
<p style="color: var(--priority-1); margin-bottom: 1rem; font-size: 0.9rem;">
Copy this key now. You won't be able to see it again!
</p>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input type="text" id="newKeyValue" readonly
style="flex: 1; padding: 0.75rem; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-primary); border: 2px solid var(--terminal-green); color: var(--terminal-green);">
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">Copy</button>
<!-- New key display (hidden by default) -->
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
<div class="lt-subsection-header lt-text-amber">&#x26A0; Copy this key now — you won't see it again!</div>
<div class="lt-flex lt-flex-gap-sm lt-mt-sm">
<input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace;opacity:1;cursor:text">
<button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button>
</div>
</div>
</div>
</div>
<!-- Existing Keys Table -->
<div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Existing API Keys</h3>
<table style="width: 100%; font-size: 0.9rem;">
<!-- Existing keys -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Existing API Keys</div>
<div class="lt-section-body">
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="API keys">
<thead>
<tr>
<th>Name</th>
<th>Key Prefix</th>
<th>Created By</th>
<th>Created At</th>
<th>Expires At</th>
<th>Last Used</th>
<th>Status</th>
<th>Actions</th>
<th scope="col">Name</th>
<th scope="col">Key Prefix</th>
<th scope="col">Created By</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
<th scope="col">Last Used</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($apiKeys)): ?>
<tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No API keys found. Generate one above.
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php else: foreach ($apiKeys as $key): ?>
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>&hellip;</code></td>
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
<?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
</td>
<td data-label="Last Used" class="lt-text-xs lt-text-muted">
<?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
</td>
<td data-label="Status">
<?php if ($key['is_active']): ?>
<span class="lt-status lt-status-open">Active</span>
<?php else: ?>
<span class="lt-status lt-status-closed">Revoked</span>
<?php endif ?>
</td>
<td data-label="Actions">
<?php if ($key['is_active']): ?>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="revoke-key" data-id="<?= (int)$key['api_key_id'] ?>">REVOKE</button>
<?php else: ?>
<span class="lt-text-muted lt-text-xs"></span>
<?php endif ?>
</td>
</tr>
<?php else: ?>
<?php foreach ($apiKeys as $key): ?>
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
<td style="font-family: var(--font-mono);">
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
</td>
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td style="white-space: nowrap;">
<?php if ($key['expires_at']): ?>
<?php
$expired = strtotime($key['expires_at']) < time();
$color = $expired ? 'var(--priority-1)' : 'var(--terminal-green)';
?>
<span style="color: <?php echo $color; ?>;">
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
<?php if ($expired): ?> (Expired)<?php endif; ?>
</span>
<?php else: ?>
<span style="color: var(--terminal-cyan);">Never</span>
<?php endif; ?>
</td>
<td style="white-space: nowrap;">
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<span style="color: var(--status-open);">Active</span>
<?php else: ?>
<span style="color: var(--status-closed);">Revoked</span>
<?php endif; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">
Revoke
</button>
<?php else: ?>
<span style="color: var(--text-muted);">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- API Usage Info -->
<div class="ascii-frame-inner" style="margin-top: 1.5rem;">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">API Usage</h3>
<p style="margin-bottom: 0.5rem;">Include the API key in your requests using the Authorization header:</p>
<pre style="background: var(--bg-primary); padding: 1rem; border: 1px solid var(--terminal-green); overflow-x: auto;"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);">
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
<!-- API usage -->
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">API Usage</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted">Include the API key in your requests using the Authorization header:</p>
<div class="lt-code-block">
<div class="lt-code-header">
<span class="lt-code-lang">HTTP HEADER</span>
<button type="button" class="lt-code-copy lt-btn-sm"
data-copy="Authorization: Bearer YOUR_API_KEY"
data-copy-toast>COPY</button>
</div>
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Example create a ticket via cURL:<br>
</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 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'copy-api-key':
copyApiKey();
break;
case 'revoke-key':
revokeKey(target.dataset.id);
switch (target.getAttribute('data-action')) {
case 'copy-api-key': copyApiKey(); break;
case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
case 'copy-header-example':
navigator.clipboard.writeText('Authorization: Bearer YOUR_API_KEY')
.then(function() { lt.toast.success('Copied!'); })
.catch(function() { lt.toast.error('Copy failed'); });
break;
}
});
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
e.preventDefault();
const keyName = document.getElementById('keyName').value.trim();
const expiresIn = document.getElementById('expiresIn').value;
if (!keyName) {
showToast('Please enter a key name', 'error');
return;
}
try {
const response = await fetch('/api/generate_api_key.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
key_name: keyName,
expires_in_days: expiresIn || null
})
});
const data = await response.json();
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null })
.then(function (data) {
if (data.success) {
// Show the new key
document.getElementById('newKeyValue').value = data.api_key;
document.getElementById('newKeyDisplay').style.display = 'block';
document.getElementById('newKeyDisplay').classList.remove('is-hidden');
document.getElementById('keyName').value = '';
showToast('API key generated successfully', 'success');
// Reload page after 5 seconds to show new key in table
setTimeout(() => location.reload(), 5000);
lt.toast.success('API key generated!');
setTimeout(function () { location.reload(); }, 5000);
} else {
showToast(data.error || 'Failed to generate API key', 'error');
}
} catch (error) {
showToast('Error generating API key: ' + error.message, 'error');
lt.toast.error(data.error || 'Failed to generate API key');
}
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
});
function copyApiKey() {
const keyInput = document.getElementById('newKeyValue');
keyInput.select();
document.execCommand('copy');
showToast('API key copied to clipboard', 'success');
}
async function revokeKey(keyId) {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
return;
}
try {
const response = await fetch('/api/revoke_api_key.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ key_id: keyId })
var val = document.getElementById('newKeyValue').value;
lt.copy(val).then(function () {
lt.toast.success('Copied to clipboard!');
}).catch(function () {
lt.toast.error('Copy failed — select the key manually');
});
}
const data = await response.json();
function revokeKey(keyId) {
showConfirmModal('Revoke API Key', 'Revoke this API key? This cannot be undone.', 'error', function () {
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
.then(function (data) {
if (data.success) { lt.toast.success('API key revoked'); location.reload(); }
else lt.toast.error(data.error || 'Failed to revoke');
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
});
}
if (data.success) {
showToast('API key revoked successfully', 'success');
location.reload();
} else {
showToast(data.error || 'Failed to revoke API key', 'error');
}
} catch (error) {
showToast('Error revoking API key: ' + error.message, 'error');
}
}
if (window.lt) lt.keys.initDefaults();
</script>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+103 -108
View File
@@ -1,157 +1,152 @@
<?php
// Admin view for browsing audit logs
// Receives $auditLogs, $totalPages, $page, $filters from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Audit Log';
$activeNav = 'admin-audit-log';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Log - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Audit Log</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Audit Log</span>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1400px; margin: 2rem auto;">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Audit Log Browser</div>
<div class="lt-section-body">
<div class="ascii-section-header">Audit Log Browser</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<!-- Filters -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Action Type</label>
<select name="action_type" class="setting-select">
<form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search" aria-label="Filter audit logs">
<div class="lt-form-group" style="margin:0">
<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>
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
<option value="delete" <?php echo ($filters['action_type'] ?? '') === 'delete' ? 'selected' : ''; ?>>Delete</option>
<option value="comment" <?php echo ($filters['action_type'] ?? '') === 'comment' ? 'selected' : ''; ?>>Comment</option>
<option value="assign" <?php echo ($filters['action_type'] ?? '') === 'assign' ? 'selected' : ''; ?>>Assign</option>
<option value="status_change" <?php echo ($filters['action_type'] ?? '') === 'status_change' ? 'selected' : ''; ?>>Status Change</option>
<option value="login" <?php echo ($filters['action_type'] ?? '') === 'login' ? 'selected' : ''; ?>>Login</option>
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] 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 ?>
</select>
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">User</label>
<select name="user_id" class="setting-select">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="user_id">User</label>
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
<option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $user): ?>
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
<?php if (isset($users)): foreach ($users as $u): ?>
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach; endif; ?>
<?php endforeach; endif ?>
</select>
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="setting-select">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_from'] ?? '') ?>">
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="setting-select">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_to'] ?? '') ?>">
</div>
<div style="display: flex; align-items: flex-end;">
<button type="submit" class="btn">Filter</button>
<a href="?" class="btn btn-secondary" style="margin-left: 0.5rem;">Reset</a>
<div class="lt-form-group lt-flex lt-flex-align-center lt-flex-gap-sm" style="margin:0;align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">FILTER</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
</form>
<!-- Log Table -->
<table style="width: 100%; font-size: 0.9rem;">
<!-- Log table -->
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Audit log entries">
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Entity ID</th>
<th>Details</th>
<th>IP Address</th>
<th scope="col">Timestamp</th>
<th scope="col">User</th>
<th scope="col">Action</th>
<th scope="col">Entity</th>
<th scope="col">Entity ID</th>
<th scope="col">Details</th>
<th scope="col">IP Address</th>
</tr>
</thead>
<tbody>
<?php if (empty($auditLogs)): ?>
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
<?php else: foreach ($auditLogs as $log): ?>
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No audit log entries found.
</td>
</tr>
<?php else: ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
<td>
<span style="color: var(--terminal-amber);"><?php echo htmlspecialchars($log['action_type']); ?></span>
</td>
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
<td>
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
<td data-label="Action"><span class="lt-text-amber"><?= htmlspecialchars($log['action_type']) ?></span></td>
<td data-label="Entity" class="lt-text-xs"><?= htmlspecialchars($log['entity_type'] ?? '-') ?></td>
<td data-label="Entity ID" class="lt-text-xs">
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" style="color: var(--terminal-green);">
<?php echo htmlspecialchars($log['entity_id']); ?>
</a>
<a href="/ticket/<?= htmlspecialchars($log['entity_id']) ?>"><?= htmlspecialchars($log['entity_id']) ?></a>
<?php else: ?>
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
<?php endif; ?>
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
<?php endif ?>
</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">
<td data-label="Details" class="lt-text-xs lt-text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">
<?php
if ($log['details']) {
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
if (is_array($details)) {
echo '<code style="font-size: 0.8rem;">' . htmlspecialchars(json_encode($details)) . '</code>';
} else {
echo htmlspecialchars($log['details']);
}
$det = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
echo '<code>' . htmlspecialchars(is_array($det) ? json_encode($det) : (string)$log['details']) . '</code>';
} else {
echo '-';
}
?>
</td>
<td style="white-space: nowrap; font-size: 0.85rem;"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<div class="pagination" style="margin-top: 1rem; text-align: center;">
<?php if (($totalPages ?? 1) > 1): ?>
<div class="lt-pagination" role="navigation">
<?php
$params = $_GET;
for ($i = 1; $i <= min($totalPages, 10); $i++) {
$params['page'] = $i;
$activeClass = ($i == $page) ? 'active' : '';
$url = '?' . http_build_query($params);
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
$start = max(1, $page - 2);
$end = min($totalPages, $page + 2);
if ($page > 1) {
$params['page'] = $page - 1;
$pUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo '<a href="' . $pUrl . '" class="lt-btn lt-btn-sm" aria-label="Previous page">&#xAB;</a> ';
}
if ($totalPages > 10) {
echo "...";
if ($start > 1) {
$params['page'] = 1;
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
if ($start > 2) echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
}
for ($i = $start; $i <= $end; $i++) {
$params['page'] = $i;
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
$class = ($i == $page) ? ' lt-btn-primary' : '';
$curr = ($i == $page) ? ' aria-current="page"' : '';
echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
}
if ($end < $totalPages) {
if ($end < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
$params['page'] = $totalPages;
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
}
if ($page < $totalPages) {
$params['page'] = $page + 1;
$nUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo '<a href="' . $nUrl . '" class="lt-btn lt-btn-sm" aria-label="Next page">&#xBB;</a>';
}
?>
</div>
<?php endif; ?>
<?php endif ?>
</div>
</div>
</div>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+157 -210
View File
@@ -1,90 +1,73 @@
<?php
// Admin view for managing custom fields
// Receives $customFields from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Custom Fields';
$activeNav = 'admin-custom-fields';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Fields - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Custom Fields</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Custom Fields</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW FIELD</button>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<div class="ascii-section-header">Custom Fields Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ New Field</button>
</div>
<table style="width: 100%;">
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Custom Field Definitions</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
Custom fields extend tickets with additional metadata. Fields appear on the ticket form based on category.
</p>
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Custom fields">
<thead>
<tr>
<th>Order</th>
<th>Field Name</th>
<th>Label</th>
<th>Type</th>
<th>Category</th>
<th>Required</th>
<th>Status</th>
<th>Actions</th>
<th scope="col">Order</th>
<th scope="col">Field Name</th>
<th scope="col">Label</th>
<th scope="col">Type</th>
<th scope="col">Category</th>
<th scope="col">Required</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($customFields)): ?>
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
<?php else: foreach ($customFields as $field): ?>
<tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No custom fields defined.
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
<td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
<td data-label="Required" class="lt-text-center">
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
</tr>
<?php else: ?>
<?php foreach ($customFields as $field): ?>
<tr>
<td><?php echo $field['display_order']; ?></td>
<td><code><?php echo htmlspecialchars($field['field_name']); ?></code></td>
<td><?php echo htmlspecialchars($field['field_label']); ?></td>
<td><?php echo ucfirst($field['field_type']); ?></td>
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
<td>
<span style="color: <?php echo $field['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
<td data-label="Status">
<span class="lt-status <?= $field['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $field['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td>
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">Delete</button>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-field" data-id="<?= $field['field_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-field" data-id="<?= $field['field_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
@@ -92,26 +75,30 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="fieldModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 500px;">
<div class="settings-header">
<h3 id="modalTitle">Create Custom Field</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="cfModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="cfModalTitle">Create Custom Field</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="fieldForm">
<input type="hidden" id="field_id" name="field_id">
<div class="settings-body">
<div class="setting-row">
<label for="field_name">Field Name * (internal)</label>
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="field_name">Field Name * <span class="lt-text-muted lt-text-xs">(internal, lowercase_underscore)</span></label>
<input type="text" id="field_name" name="field_name" class="lt-input" required
pattern="[a-z_]+" placeholder="e.g., server_name">
</div>
<div class="setting-row">
<label for="field_label">Field Label * (display)</label>
<input type="text" id="field_label" name="field_label" required placeholder="e.g., Server Name">
<div class="lt-form-group">
<label class="lt-label" for="field_label">Field Label * <span class="lt-text-muted lt-text-xs">(display name)</span></label>
<input type="text" id="field_label" name="field_label" class="lt-input" required
placeholder="e.g., Server Name">
</div>
<div class="setting-row">
<label for="field_type">Field Type *</label>
<select id="field_type" name="field_type" required data-action="toggle-options-field">
<div class="lt-form-group">
<label class="lt-label" for="field_type">Field Type *</label>
<select id="field_type" name="field_type" class="lt-select" required
data-action="toggle-options-field">
<option value="text">Text</option>
<option value="textarea">Text Area</option>
<option value="select">Dropdown (Select)</option>
@@ -120,181 +107,141 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="number">Number</option>
</select>
</div>
<div class="setting-row" id="options_row" style="display: none;">
<label for="field_options">Options (one per line)</label>
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
<div class="lt-form-group is-hidden" id="options_row">
<label class="lt-label" for="field_options">Options <span class="lt-text-muted lt-text-xs">(one per line)</span></label>
<textarea id="field_options" name="field_options" class="lt-input lt-textarea"
rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div>
<div class="setting-row">
<label for="category">Category (empty = all)</label>
<select id="category" name="category">
<div class="lt-form-group">
<label class="lt-label" for="cf-category">Category <span class="lt-text-muted lt-text-xs">(empty = all categories)</span></label>
<select id="cf-category" name="category" class="lt-select">
<option value="">All Categories</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row">
<label for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" value="0" min="0">
<div class="lt-form-group">
<label class="lt-label" for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" class="lt-input"
value="0" min="0" style="max-width:8rem">
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_required" name="is_required"> Required field</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_required" name="is_required">
Required field
</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="cf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Custom Field';
document.getElementById('fieldForm').reset();
document.getElementById('field_id').value = '';
document.getElementById('is_active').checked = true;
toggleOptionsField();
document.getElementById('fieldModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('fieldModal').style.display = 'none';
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-field':
editField(target.dataset.id);
break;
case 'delete-field':
deleteField(target.dataset.id);
break;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-field': editField(target.getAttribute('data-id')); break;
case 'delete-field': deleteField(target.getAttribute('data-id')); break;
}
});
document.addEventListener('change', function(event) {
const target = event.target.closest('[data-action]');
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
if (target.dataset.action === 'toggle-options-field') {
toggleOptionsField();
}
if (target.getAttribute('data-action') === 'toggle-options-field') toggleOptionsField();
});
// Form submit handler
document.getElementById('fieldForm').addEventListener('submit', function (e) {
saveField(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
if (window.lt) lt.keys.initDefaults();
function toggleOptionsField() {
const type = document.getElementById('field_type').value;
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
var type = document.getElementById('field_type').value;
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
}
function saveField(e) {
e.preventDefault();
const form = document.getElementById('fieldForm');
const data = {
field_id: document.getElementById('field_id').value,
field_name: document.getElementById('field_name').value,
field_label: document.getElementById('field_label').value,
field_type: document.getElementById('field_type').value,
category: document.getElementById('category').value || null,
display_order: parseInt(document.getElementById('display_order').value) || 0,
is_required: document.getElementById('is_required').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
if (data.field_type === 'select') {
const options = document.getElementById('field_options').value.split('\n').filter(o => o.trim());
data.field_options = { options: options };
}
const method = data.field_id ? 'PUT' : 'POST';
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) {
window.location.reload();
} else {
toast.error(result.error || 'Failed to save');
}
});
function showCreateModal() {
document.getElementById('cfModalTitle').textContent = 'Create Custom Field';
document.getElementById('fieldForm').reset();
document.getElementById('field_id').value = '';
document.getElementById('cf_is_active').checked = true;
toggleOptionsField();
lt.modal.open('fieldModal');
}
function editField(id) {
fetch('/api/custom_fields.php?id=' + id)
.then(r => r.json())
.then(data => {
lt.api.get('/api/custom_fields.php?id=' + id)
.then(function (data) {
if (data.success && data.field) {
const f = data.field;
var f = data.field;
document.getElementById('field_id').value = f.field_id;
document.getElementById('field_name').value = f.field_name;
document.getElementById('field_label').value = f.field_label;
document.getElementById('field_type').value = f.field_type;
document.getElementById('category').value = f.category || '';
document.getElementById('cf-category').value = f.category || '';
document.getElementById('display_order').value = f.display_order;
document.getElementById('is_required').checked = f.is_required == 1;
document.getElementById('is_active').checked = f.is_active == 1;
document.getElementById('cf_is_active').checked = f.is_active == 1;
toggleOptionsField();
if (f.field_options && f.field_options.options) {
document.getElementById('field_options').value = f.field_options.options.join('\n');
}
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
document.getElementById('fieldModal').style.display = 'flex';
document.getElementById('cfModalTitle').textContent = 'Edit Custom Field';
lt.modal.open('fieldModal');
} else {
lt.toast.error(data.error || 'Failed to load field');
}
});
}).catch(function () { lt.toast.error('Failed to load field'); });
}
function deleteField(id) {
if (!confirm('Delete this custom field? All values will be lost.')) return;
fetch('/api/custom_fields.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => {
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function () {
lt.api.delete('/api/custom_fields.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveField(e) {
e.preventDefault();
var data = {
field_id: document.getElementById('field_id').value,
field_name: document.getElementById('field_name').value,
field_label: document.getElementById('field_label').value,
field_type: document.getElementById('field_type').value,
category: document.getElementById('cf-category').value || null,
display_order: parseInt(document.getElementById('display_order').value) || 0,
is_required: document.getElementById('is_required').checked ? 1 : 0,
is_active: document.getElementById('cf_is_active').checked ? 1 : 0,
};
if (data.field_type === 'select') {
var opts = document.getElementById('field_options').value.split('\n').filter(function (o) { return o.trim(); });
data.field_options = { options: opts };
}
var url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
var apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+196 -262
View File
@@ -1,75 +1,48 @@
<?php
// Admin view for managing recurring tickets
// Receives $recurringTickets from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Recurring Tickets';
$activeNav = 'admin-recurring';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Tickets - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Recurring Tickets</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Recurring Tickets</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW RECURRING TICKET</button>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<div class="ascii-section-header">Recurring Tickets Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Scheduled Tickets</h2>
<button data-action="show-create-modal" class="btn">+ New Recurring Ticket</button>
</div>
<table style="width: 100%;">
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Scheduled Tickets</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
Recurring tickets are automatically created on a schedule. Use <code>{{date}}</code>, <code>{{month}}</code>, <code>{{year}}</code> in title templates.
</p>
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Recurring tickets">
<thead>
<tr>
<th>ID</th>
<th>Title Template</th>
<th>Schedule</th>
<th>Category</th>
<th>Assigned To</th>
<th>Next Run</th>
<th>Status</th>
<th>Actions</th>
<th scope="col">Title Template</th>
<th scope="col">Schedule</th>
<th scope="col">Category</th>
<th scope="col">Assigned To</th>
<th scope="col">Next Run</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($recurringTickets)): ?>
<tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No recurring tickets configured.
</td>
</tr>
<?php else: ?>
<?php foreach ($recurringTickets as $rt): ?>
<tr>
<td><?php echo $rt['recurring_id']; ?></td>
<td><?php echo htmlspecialchars($rt['title_template']); ?></td>
<td>
<tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
<?php else: foreach ($recurringTickets as $rt): ?>
<?php
$schedule = ucfirst($rt['schedule_type']);
if ($rt['schedule_type'] === 'weekly') {
@@ -79,27 +52,36 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
}
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
echo $schedule;
?>
<tr>
<td data-label="Title"><strong><?= htmlspecialchars($rt['title_template']) ?></strong></td>
<td data-label="Schedule" class="lt-text-xs lt-text-cyan"><?= htmlspecialchars($schedule) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($rt['category']) ?></td>
<td data-label="Assigned To" class="lt-text-xs">
<?= htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned') ?>
</td>
<td><?php echo htmlspecialchars($rt['category']); ?></td>
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
<td><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
<td>
<span style="color: <?php echo $rt['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
<td data-label="Next Run" class="lt-text-xs lt-text-muted">
<?= date('M d, Y H:i', strtotime($rt['next_run_at'])) ?>
</td>
<td data-label="Status">
<span class="lt-status <?= $rt['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $rt['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td>
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
<?php echo $rt['is_active'] ? 'Disable' : 'Enable'; ?>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-recurring" data-id="<?= $rt['recurring_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm"
data-action="toggle-recurring" data-id="<?= $rt['recurring_id'] ?>">
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
</button>
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">Delete</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-recurring" data-id="<?= $rt['recurring_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
@@ -107,266 +89,218 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="recurringModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 800px; width: 90%;">
<div class="settings-header">
<h3 id="modalTitle">Create Recurring Ticket</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="recModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="recModalTitle">Create Recurring Ticket</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id">
<div class="settings-body">
<div class="setting-row">
<label for="title_template">Title Template *</label>
<input type="text" id="title_template" name="title_template" required style="width: 100%;" placeholder="Use {{date}}, {{month}}, etc.">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="rec_title_template">Title Template *</label>
<input type="text" id="rec_title_template" name="title_template" class="lt-input" required
placeholder="Use {{date}}, {{month}}, {{year}}">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="8" style="width: 100%; min-height: 150px;"></textarea>
<div class="lt-form-group">
<label class="lt-label" for="rec_description_template">Description Template</label>
<textarea id="rec_description_template" name="description_template"
class="lt-input lt-textarea" rows="6"></textarea>
</div>
<div class="setting-row">
<label for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" required data-action="update-schedule-options">
<div class="lt-form-group">
<label class="lt-label" for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" class="lt-select" required
data-action="update-schedule-options">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="setting-row" id="schedule_day_row" style="display: none;">
<label for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day"></select>
<div class="lt-form-group is-hidden" id="schedule_day_row">
<label class="lt-label" for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day" class="lt-select"></select>
</div>
<div class="setting-row">
<label for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
<div class="lt-form-group">
<label class="lt-label" for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" class="lt-input"
value="09:00" required style="max-width:12rem">
</div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="rec-category">Category</label>
<select id="rec-category" name="category" class="lt-select">
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<option value="Issue">Issue</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Problem">Problem</option>
<div class="lt-form-group">
<label class="lt-label" for="rec-type">Type</label>
<select id="rec-type" name="type" class="lt-select">
<?php foreach (['Issue','Maintenance','Install','Task','Upgrade','Problem'] as $t): ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1 - Critical</option>
<option value="2">P2 - High</option>
<option value="3">P3 - Medium</option>
<option value="4" selected>P4 - Low</option>
<option value="5">P5 - Lowest</option>
<div class="lt-form-group">
<label class="lt-label" for="rec-priority">Priority</label>
<select id="rec-priority" name="priority" class="lt-select">
<option value="1">P1 Critical</option>
<option value="2">P2 High</option>
<option value="3">P3 Medium</option>
<option value="4" selected>P4 Low</option>
<option value="5">P5 Lowest</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to">
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value="">Unassigned</option>
<!-- Populated by JavaScript -->
<!-- Populated by JS -->
</select>
</div>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
document.getElementById('recurringModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('recurringModal').style.display = 'none';
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-recurring':
editRecurring(target.dataset.id);
break;
case 'toggle-recurring':
toggleRecurring(target.dataset.id);
break;
case 'delete-recurring':
deleteRecurring(target.dataset.id);
break;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-recurring': editRecurring(target.getAttribute('data-id')); break;
case 'toggle-recurring': toggleRecurring(target.getAttribute('data-id')); break;
case 'delete-recurring': deleteRecurring(target.getAttribute('data-id')); break;
}
});
document.addEventListener('change', function(event) {
const target = event.target.closest('[data-action]');
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
if (target.dataset.action === 'update-schedule-options') {
updateScheduleOptions();
}
if (target.getAttribute('data-action') === 'update-schedule-options') updateScheduleOptions();
});
// Form submit handler
document.getElementById('recurringForm').addEventListener('submit', function (e) {
saveRecurring(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
if (window.lt) lt.keys.initDefaults();
function updateScheduleOptions() {
const type = document.getElementById('schedule_type').value;
const dayRow = document.getElementById('schedule_day_row');
const daySelect = document.getElementById('schedule_day');
var type = document.getElementById('schedule_type').value;
var dayRow = document.getElementById('schedule_day_row');
var daySelect = document.getElementById('schedule_day');
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.style.display = 'none';
dayRow.classList.add('is-hidden');
} else if (type === 'weekly') {
dayRow.style.display = 'flex';
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
days.forEach((day, i) => {
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
dayRow.classList.remove('is-hidden');
['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'].forEach(function (day, i) {
var opt = document.createElement('option');
opt.value = String(i + 1);
opt.textContent = day;
daySelect.appendChild(opt);
});
} else if (type === 'monthly') {
dayRow.style.display = 'flex';
for (let i = 1; i <= 28; i++) {
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
dayRow.classList.remove('is-hidden');
for (var i = 1; i <= 31; i++) {
var opt = document.createElement('option');
opt.value = String(i);
opt.textContent = 'Day ' + i + (i > 28 ? ' (last day in short months)' : '');
daySelect.appendChild(opt);
}
}
}
function saveRecurring(e) {
e.preventDefault();
const form = new FormData(document.getElementById('recurringForm'));
const data = Object.fromEntries(form);
const method = data.recurring_id ? 'PUT' : 'POST';
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
toast.error(data.error || 'Failed to save');
}
});
}
function toggleRecurring(id) {
fetch('/api/manage_recurring.php?action=toggle&id=' + id, {
method: 'POST',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => {
if (data.success) window.location.reload();
});
}
function deleteRecurring(id) {
if (!confirm('Delete this recurring ticket schedule?')) return;
fetch('/api/manage_recurring.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => {
if (data.success) window.location.reload();
});
function showCreateModal() {
document.getElementById('recModalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
lt.modal.open('recurringModal');
}
function editRecurring(id) {
fetch('/api/manage_recurring.php?id=' + id)
.then(r => r.json())
.then(data => {
lt.api.get('/api/manage_recurring.php?id=' + id)
.then(function (data) {
if (data.success && data.recurring) {
const rt = data.recurring;
var rt = data.recurring;
document.getElementById('recurring_id').value = rt.recurring_id;
document.getElementById('title_template').value = rt.title_template;
document.getElementById('description_template').value = rt.description_template || '';
document.getElementById('rec_title_template').value = rt.title_template;
document.getElementById('rec_description_template').value = rt.description_template || '';
document.getElementById('schedule_type').value = rt.schedule_type;
updateScheduleOptions();
document.getElementById('schedule_day').value = rt.schedule_day || '';
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
document.getElementById('category').value = rt.category || 'General';
document.getElementById('type').value = rt.type || 'Issue';
document.getElementById('priority').value = rt.priority || 4;
document.getElementById('rec-category').value = rt.category || 'General';
document.getElementById('rec-type').value = rt.type || 'Issue';
document.getElementById('rec-priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
document.getElementById('recurringModal').style.display = 'flex';
document.getElementById('recModalTitle').textContent = 'Edit Recurring Ticket';
lt.modal.open('recurringModal');
} else {
lt.toast.error(data.error || 'Failed to load schedule');
}
}).catch(function () { lt.toast.error('Failed to load schedule'); });
}
function toggleRecurring(id) {
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to toggle');
}).catch(function () { lt.toast.error('Failed to toggle'); });
}
function deleteRecurring(id) {
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_recurring.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
// Load users for assignee dropdown
function saveRecurring(e) {
e.preventDefault();
var form = new FormData(document.getElementById('recurringForm'));
var data = {};
form.forEach(function (v, k) { data[k] = v; });
var url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
var apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
function loadUsers() {
fetch('/api/get_users.php')
.then(r => r.json())
.then(data => {
lt.api.get('/api/get_users.php')
.then(function (data) {
if (data.success && data.users) {
const select = document.getElementById('assigned_to');
data.users.forEach(user => {
const option = document.createElement('option');
option.value = user.user_id;
option.textContent = user.display_name || user.username;
select.appendChild(option);
var select = document.getElementById('assigned_to');
data.users.forEach(function (user) {
var opt = document.createElement('option');
opt.value = user.user_id;
opt.textContent = user.display_name || user.username;
select.appendChild(opt);
});
}
});
}).catch(function () { /* non-critical: assigned_to stays as manual input */ });
}
// Initialize
updateScheduleOptions();
loadUsers();
</script>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+140 -206
View File
@@ -1,90 +1,68 @@
<?php
// Admin view for managing ticket templates
// Receives $templates from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Templates';
$activeNav = 'admin-templates';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Management - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Templates</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Templates</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TEMPLATE</button>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<div class="ascii-section-header">Ticket Template Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ New Template</button>
</div>
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Ticket Template Management</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
Templates pre-fill ticket creation forms with standard content for common ticket types.
</p>
<table style="width: 100%;">
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Ticket templates">
<thead>
<tr>
<th>Template Name</th>
<th>Category</th>
<th>Type</th>
<th>Priority</th>
<th>Active</th>
<th>Actions</th>
<th scope="col">Template Name</th>
<th scope="col">Category</th>
<th scope="col">Type</th>
<th scope="col">Priority</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
<?php else: foreach ($templates as $tpl): ?>
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No templates defined. Create templates to speed up ticket creation.
</td>
</tr>
<?php else: ?>
<?php foreach ($templates as $tpl): ?>
<tr>
<td><strong><?php echo htmlspecialchars($tpl['template_name']); ?></strong></td>
<td><?php echo htmlspecialchars($tpl['category'] ?? 'Any'); ?></td>
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
<td>
<span style="color: <?php echo ($tpl['is_active'] ?? 1) ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
<?php $tp = (int)($tpl['default_priority'] ?? 4); $tBadge = match($tp) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
<td data-label="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
<td data-label="Status">
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive' ?>
</span>
</td>
<td>
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">Delete</button>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-template" data-id="<?= $tpl['template_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-template" data-id="<?= $tpl['template_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
@@ -92,188 +70,144 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="templateModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 800px; width: 90%;">
<div class="settings-header">
<h3 id="modalTitle">Create Template</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Template</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="templateForm">
<input type="hidden" id="template_id" name="template_id">
<div class="settings-body">
<div class="setting-row">
<label for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" required style="width: 100%;">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" class="lt-input" required>
</div>
<div class="setting-row">
<label for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" style="width: 100%;" placeholder="Pre-filled title text">
<div class="lt-form-group">
<label class="lt-label" for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" class="lt-input"
placeholder="Pre-filled title text">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="10" style="width: 100%; min-height: 200px;" placeholder="Pre-filled description content"></textarea>
<div class="lt-form-group">
<label class="lt-label" for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" class="lt-input lt-textarea"
rows="10" placeholder="Pre-filled description content"></textarea>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="tpl-category">Category</label>
<select id="tpl-category" name="category" class="lt-select">
<option value="">Any</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<div class="lt-form-group">
<label class="lt-label" for="tpl-type">Type</label>
<select id="tpl-type" name="type" class="lt-select">
<option value="">Any</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue">Issue</option>
<option value="Problem">Problem</option>
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1</option>
<option value="2">P2</option>
<option value="3">P3</option>
<option value="4" selected>P4</option>
<option value="5">P5</option>
<div class="lt-form-group">
<label class="lt-label" for="tpl-priority">Priority</label>
<select id="tpl-priority" name="priority" class="lt-select">
<?php foreach ([1=>'P1',2=>'P2',3=>'P3',4=>'P4 (default)',5=>'P5'] as $v=>$l): ?>
<option value="<?= $v ?>" <?= $v === 4 ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
const templates = <?php echo json_encode($templates ?? []); ?>;
<script nonce="<?= $nonce ?>">
var templates = <?= json_encode($templates ?? [], JSON_HEX_TAG) ?>;
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-template': editTemplate(target.getAttribute('data-id')); break;
case 'delete-template': deleteTemplate(target.getAttribute('data-id')); break;
}
});
document.getElementById('templateForm').addEventListener('submit', function (e) {
saveTemplate(e);
});
if (window.lt) lt.keys.initDefaults();
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Template';
document.getElementById('templateForm').reset();
document.getElementById('template_id').value = '';
document.getElementById('is_active').checked = true;
document.getElementById('templateModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('templateModal').style.display = 'none';
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-template':
editTemplate(target.dataset.id);
break;
case 'delete-template':
deleteTemplate(target.dataset.id);
break;
}
});
// Form submit handler
document.getElementById('templateForm').addEventListener('submit', function(e) {
saveTemplate(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
function saveTemplate(e) {
e.preventDefault();
const data = {
template_id: document.getElementById('template_id').value,
template_name: document.getElementById('template_name').value,
title_template: document.getElementById('title_template').value,
description_template: document.getElementById('description_template').value,
category: document.getElementById('category').value || null,
type: document.getElementById('type').value || null,
default_priority: parseInt(document.getElementById('priority').value) || 4,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
const method = data.template_id ? 'PUT' : 'POST';
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) {
window.location.reload();
} else {
toast.error(result.error || 'Failed to save');
}
});
lt.modal.open('templateModal');
}
function editTemplate(id) {
const tpl = templates.find(t => t.template_id == id);
var tpl = templates.find(function (t) { return t.template_id == id; });
if (!tpl) return;
document.getElementById('template_id').value = tpl.template_id;
document.getElementById('template_name').value = tpl.template_name;
document.getElementById('title_template').value = tpl.title_template || '';
document.getElementById('description_template').value = tpl.description_template || '';
document.getElementById('category').value = tpl.category || '';
document.getElementById('type').value = tpl.type || '';
document.getElementById('priority').value = tpl.default_priority || 4;
document.getElementById('tpl-category').value = tpl.category || '';
document.getElementById('tpl-type').value = tpl.type || '';
document.getElementById('tpl-priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template';
document.getElementById('templateModal').style.display = 'flex';
lt.modal.open('templateModal');
}
function deleteTemplate(id) {
if (!confirm('Delete this template?')) return;
fetch('/api/manage_templates.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => {
showConfirmModal('Delete Template', 'Delete this template? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_templates.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveTemplate(e) {
e.preventDefault();
var data = {
template_id: document.getElementById('template_id').value,
template_name: document.getElementById('template_name').value,
title_template: document.getElementById('title_template').value,
description_template: document.getElementById('description_template').value,
category: document.getElementById('tpl-category').value || null,
type: document.getElementById('tpl-type').value || null,
default_priority: parseInt(document.getElementById('tpl-priority').value) || 4,
is_active: document.getElementById('is_active').checked ? 1 : 0,
};
var url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
var apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+95 -113
View File
@@ -1,135 +1,117 @@
<?php
// Admin view for user activity reports
// Receives $userStats, $dateRange from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'User Activity';
$activeNav = 'admin-user-activity';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Activity - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: User Activity</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: User Activity</span>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">User Activity Report</div>
<div class="lt-section-body">
<div class="ascii-section-header">User Activity Report</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<!-- Date Range Filter -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: flex-end;">
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="setting-select">
<!-- Date filter -->
<form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($dateRange['from'] ?? '') ?>">
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="setting-select">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($dateRange['to'] ?? '') ?>">
</div>
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
<button type="submit" class="btn">Apply</button>
<a href="?" class="btn btn-secondary">Reset</a>
</form>
<!-- User Activity Table -->
<table style="width: 100%;">
<!-- Summary stats -->
<?php if (!empty($userStats)): ?>
<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-label">Active Users</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon">[ + ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_created')) ?></div>
<div class="lt-stat-label">Total Created</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_resolved')) ?></div>
<div class="lt-stat-label">Total Resolved</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-amber">[ > ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'comments_added')) ?></div>
<div class="lt-stat-label">Total Comments</div>
</div>
</div>
</div>
<?php endif ?>
<!-- User activity table -->
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="User activity">
<thead>
<tr>
<th>User</th>
<th style="text-align: center;">Tickets Created</th>
<th style="text-align: center;">Tickets Resolved</th>
<th style="text-align: center;">Comments Added</th>
<th style="text-align: center;">Tickets Assigned</th>
<th style="text-align: center;">Last Activity</th>
<th scope="col">User</th>
<th scope="col">Tickets Created</th>
<th scope="col">Tickets Resolved</th>
<th scope="col">Comments</th>
<th scope="col">Assigned</th>
<th scope="col">Last Activity</th>
</tr>
</thead>
<tbody>
<?php if (empty($userStats)): ?>
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
<?php else: foreach ($userStats as $u): ?>
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No user activity data available.
<td data-label="User">
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
<?php if ($u['is_admin']): ?>
<span class="lt-badge lt-badge-admin lt-text-xs">ADMIN</span>
<?php endif ?>
</td>
<td data-label="Created"><span class="lt-text-cyan"><?= (int)($u['tickets_created'] ?? 0) ?></span></td>
<td data-label="Resolved"><span class="lt-text-muted"><?= (int)($u['tickets_resolved'] ?? 0) ?></span></td>
<td data-label="Comments"><span class="lt-text-amber"><?= (int)($u['comments_added'] ?? 0) ?></span></td>
<td data-label="Assigned"><?= (int)($u['tickets_assigned'] ?? 0) ?></td>
<td data-label="Last Activity" class="lt-text-xs lt-text-muted">
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
</td>
</tr>
<?php else: ?>
<?php foreach ($userStats as $user): ?>
<tr>
<td>
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
<?php if ($user['is_admin']): ?>
<span class="admin-badge" style="font-size: 0.7rem;">Admin</span>
<?php endif; ?>
</td>
<td style="text-align: center;">
<span style="color: var(--terminal-green); font-weight: bold;"><?php echo $user['tickets_created'] ?? 0; ?></span>
</td>
<td style="text-align: center;">
<span style="color: var(--status-open); font-weight: bold;"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
</td>
<td style="text-align: center;">
<span style="color: var(--terminal-cyan); font-weight: bold;"><?php echo $user['comments_added'] ?? 0; ?></span>
</td>
<td style="text-align: center;">
<span style="color: var(--terminal-amber); font-weight: bold;"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
</td>
<td style="text-align: center; font-size: 0.9rem;">
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Summary Stats -->
<?php if (!empty($userStats)): ?>
<div style="margin-top: 2rem; padding: 1rem; border: 1px solid var(--terminal-green);">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Summary</h4>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; text-align: center;">
<div>
<div style="font-size: 1.5rem; color: var(--terminal-green); font-weight: bold;">
<?php echo array_sum(array_column($userStats, 'tickets_created')); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Created</div>
</div>
<div>
<div style="font-size: 1.5rem; color: var(--status-open); font-weight: bold;">
<?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Resolved</div>
</div>
<div>
<div style="font-size: 1.5rem; color: var(--terminal-cyan); font-weight: bold;">
<?php echo array_sum(array_column($userStats, 'comments_added')); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Comments</div>
</div>
<div>
<div style="font-size: 1.5rem; color: var(--terminal-amber); font-weight: bold;">
<?php echo count($userStats); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Active Users</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+148 -208
View File
@@ -1,130 +1,99 @@
<?php
// Admin view for workflow/status transitions designer
// Receives $workflows from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Workflow Designer';
$activeNav = 'admin-workflow';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workflow Designer - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link"> Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Workflow Designer</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Workflow</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TRANSITION</button>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<span class="bottom-left-corner"></span>
<span class="bottom-right-corner"></span>
<div class="ascii-section-header">Status Workflow Designer</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Status Transitions</h2>
<button data-action="show-create-modal" class="btn">+ New Transition</button>
</div>
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
Define which status transitions are allowed. This controls what options appear in the status dropdown.
</p>
<!-- Visual Workflow Diagram -->
<div style="margin-bottom: 2rem; padding: 1rem; border: 1px solid var(--terminal-green); background: var(--bg-secondary);">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Workflow Diagram</h4>
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;">
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Workflow Diagram</div>
<div class="lt-section-body">
<div class="lt-grid-4">
<?php
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
foreach ($statuses as $status):
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
?>
<div style="text-align: center;">
<div class="<?php echo $statusClass; ?>" style="padding: 0.5rem 1rem; display: inline-block;">
<?php echo $status; ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim); margin-top: 0.5rem;">
<?php
$slug = strtolower(str_replace(' ', '-', $status));
$toCount = 0;
if (isset($workflows)) {
foreach ($workflows as $w) {
if ($w['from_status'] === $status) $toCount++;
}
}
echo "$toCount transitions";
if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } }
?>
<div class="lt-card lt-text-center">
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
<div class="lt-text-xs lt-text-muted lt-mt-sm"> <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
</div>
<?php endforeach ?>
</div>
<?php endforeach; ?>
<p class="lt-text-xs lt-text-muted lt-mt-sm">
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
</p>
</div>
</div>
<!-- Transitions Table -->
<table style="width: 100%;">
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Status Transitions</div>
<div class="lt-section-body">
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Status transitions">
<thead>
<tr>
<th>From Status</th>
<th></th>
<th>To Status</th>
<th>Requires Comment</th>
<th>Requires Admin</th>
<th>Active</th>
<th>Actions</th>
<th scope="col">From Status</th>
<th scope="col">&rarr;</th>
<th scope="col">To Status</th>
<th scope="col">Req. Comment</th>
<th scope="col">Req. Admin</th>
<th scope="col">Active</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($workflows)): ?>
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
<?php else: foreach ($workflows as $wf): ?>
<?php $fromSlug = strtolower(str_replace(' ', '-', $wf['from_status'])); $toSlug = strtolower(str_replace(' ', '-', $wf['to_status'])); ?>
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No transitions defined. Add transitions to enable status changes.
<td data-label="From">
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
</td>
<td class="lt-text-amber lt-text-xs lt-text-center">&rarr;</td>
<td data-label="To">
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
</td>
<td data-label="Req. Comment" class="lt-text-center">
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Req. Admin" class="lt-text-center">
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Active" class="lt-text-center">
<?= $wf['is_active']
? '<span class="lt-text-cyan">✓</span>'
: '<span class="lt-text-danger">✗</span>' ?>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-transition" data-id="<?= $wf['transition_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-transition" data-id="<?= $wf['transition_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php else: ?>
<?php foreach ($workflows as $wf): ?>
<tr>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['from_status'])); ?>">
<?php echo htmlspecialchars($wf['from_status']); ?>
</span>
</td>
<td style="text-align: center; color: var(--terminal-amber);"></td>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
<?php echo htmlspecialchars($wf['to_status']); ?>
</span>
</td>
<td style="text-align: center;"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td style="text-align: center;"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td style="text-align: center;">
<span style="color: <?php echo $wf['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
</span>
</td>
<td>
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">Delete</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
@@ -132,161 +101,132 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="workflowModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 450px;">
<div class="settings-header">
<h3 id="modalTitle">Create Transition</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="wfModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="wfModalTitle">Create Transition</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id">
<div class="settings-body">
<div class="setting-row">
<label for="from_status">From Status *</label>
<select id="from_status" name="from_status" required>
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="from_status">From Status *</label>
<select id="from_status" name="from_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label for="to_status">To Status *</label>
<select id="to_status" name="to_status" required>
<div class="lt-form-group">
<label class="lt-label" for="to_status">To Status *</label>
<select id="to_status" name="to_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_comment" name="requires_comment"> Requires comment</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_comment" name="requires_comment">
Requires a comment when transitioning
</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_admin" name="requires_admin"> Requires admin privileges</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_admin" name="requires_admin">
Requires administrator privileges
</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="wf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>;
<script nonce="<?= $nonce ?>">
var workflows = <?= json_encode($workflows ?? [], JSON_HEX_TAG) ?>;
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Transition';
document.getElementById('workflowForm').reset();
document.getElementById('transition_id').value = '';
document.getElementById('is_active').checked = true;
document.getElementById('workflowModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('workflowModal').style.display = 'none';
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-transition':
editTransition(target.dataset.id);
break;
case 'delete-transition':
deleteTransition(target.dataset.id);
break;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-transition': editTransition(target.getAttribute('data-id')); break;
case 'delete-transition': deleteTransition(target.getAttribute('data-id')); break;
}
});
// Form submit handler
document.getElementById('workflowForm').addEventListener('submit', function (e) {
saveTransition(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
if (window.lt) lt.keys.initDefaults();
function saveTransition(e) {
e.preventDefault();
const data = {
transition_id: document.getElementById('transition_id').value,
from_status: document.getElementById('from_status').value,
to_status: document.getElementById('to_status').value,
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
const method = data.transition_id ? 'PUT' : 'POST';
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) {
window.location.reload();
} else {
toast.error(result.error || 'Failed to save');
}
});
function showCreateModal() {
document.getElementById('wfModalTitle').textContent = 'Create Transition';
document.getElementById('workflowForm').reset();
document.getElementById('transition_id').value = '';
document.getElementById('wf_is_active').checked = true;
lt.modal.open('workflowModal');
}
function editTransition(id) {
const wf = workflows.find(w => w.transition_id == id);
var wf = workflows.find(function (w) { return w.transition_id == id; });
if (!wf) return;
document.getElementById('transition_id').value = wf.transition_id;
document.getElementById('from_status').value = wf.from_status;
document.getElementById('to_status').value = wf.to_status;
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('is_active').checked = wf.is_active == 1;
document.getElementById('modalTitle').textContent = 'Edit Transition';
document.getElementById('workflowModal').style.display = 'flex';
document.getElementById('wf_is_active').checked = wf.is_active == 1;
document.getElementById('wfModalTitle').textContent = 'Edit Transition';
lt.modal.open('workflowModal');
}
function deleteTransition(id) {
if (!confirm('Delete this status transition?')) return;
fetch('/api/manage_workflows.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => {
showConfirmModal('Delete Transition', 'Delete this status transition? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_workflows.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveTransition(e) {
e.preventDefault();
var data = {
transition_id: document.getElementById('transition_id').value,
from_status: document.getElementById('from_status').value,
to_status: document.getElementById('to_status').value,
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
is_active: document.getElementById('wf_is_active').checked ? 1 : 0,
};
if (data.from_status === data.to_status) {
lt.toast.error('From Status and To Status cannot be the same');
return;
}
var url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
var apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+18
View File
@@ -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 lt-text-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">&larr; Dashboard</a>
</div>
</div>
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
+18
View File
@@ -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 lt-text-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">&larr; Dashboard</a>
</div>
</div>
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
+281
View File
@@ -0,0 +1,281 @@
<?php
/**
* layout_footer.php Shared bottom-of-page partial for all views.
*
* Expected variables available from the including view (set before require):
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
* array|null $pageScripts Optional array of extra JS paths to load after base.js
* string|null $pageInlineScript Optional raw JS string to run after all scripts load
*
* Globals used:
* $GLOBALS['currentUser'] user array (user_id, username, is_admin)
* $GLOBALS['config'] app config array (TIMEZONE, TIMEZONE_ABBREV)
* CsrfMiddleware::getToken() returns current CSRF token string
*/
// layout_footer.php — JS globals + runtime scripts are loaded here
?>
</main><!-- /#main-content / .lt-main -->
<!-- ================================================================
FOOTER keyboard hint bar + version
================================================================ -->
<?php
// Context-sensitive keyboard hints based on active nav
$_ltf_nav = $activeNav ?? 'dashboard';
$_ltf_isTicket = str_starts_with($pageTitle ?? '', 'Ticket #');
?>
<footer class="lt-footer" role="contentinfo" aria-label="Keyboard shortcuts and app info">
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
<?php if ($_ltf_isTicket): ?>
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ]</span> BACK</a>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint" title="Press 14 to change status"><span class="lt-footer-key">[ 1-4 ]</span> STATUS</span>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint" title="Press C to jump to comment box"><span class="lt-footer-key">[ C ]</span> COMMENT</span>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<?php elseif (str_starts_with($_ltf_nav, 'admin')): ?>
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<?php else: ?>
<a href="/" class="lt-footer-hint" title="Go to dashboard (G then D)"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint" title="Press / or Ctrl+K to search"><span class="lt-footer-key">[ / ]</span> SEARCH</span>
<span class="lt-footer-sep">|</span>
<a href="/ticket/create" class="lt-footer-hint" title="Create new ticket (N)"><span class="lt-footer-key">[ + ]</span> NEW</a>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<?php endif ?>
<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>
</nav>
<span aria-label="Application version"><?= htmlspecialchars($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', ENT_QUOTES, 'UTF-8') ?> &mdash; TDS v<?= htmlspecialchars($GLOBALS['config']['APP_VERSION'] ?? '1.2', ENT_QUOTES, 'UTF-8') ?></span>
</footer>
<!-- ================================================================
KEYBOARD SHORTCUTS HELP MODAL opened by ? key or footer [?] hint
================================================================ -->
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
<div class="lt-modal-header">
<span class="lt-modal-title" id="keys-help-title">Keyboard Shortcuts</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body">
<table class="lt-data-table" style="width:100%">
<thead>
<tr><th scope="col">Shortcut</th><th scope="col">Action</th></tr>
</thead>
<tbody>
<tr><td>Ctrl / &#x2318; + K</td><td>Focus search box</td></tr>
<tr><td>Ctrl / &#x2318; + E</td><td>Toggle edit mode (ticket page)</td></tr>
<tr><td>Ctrl / &#x2318; + S</td><td>Save changes (ticket page)</td></tr>
<tr><td>j / &#x2193;</td><td>Select next row</td></tr>
<tr><td>k / &#x2191;</td><td>Select previous row</td></tr>
<tr><td>Enter</td><td>Open selected ticket</td></tr>
<tr><td>n</td><td>New ticket</td></tr>
<tr><td>1&ndash;4</td><td>Change ticket status (ticket page)</td></tr>
<tr><td>c</td><td>Jump to comment box (ticket page)</td></tr>
<tr><td>?</td><td>Show this help</td></tr>
<tr><td>ESC</td><td>Close modal / cancel</td></tr>
</tbody>
</table>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<!-- ================================================================
COMMAND PALETTE Ctrl+K opens when no search input focused
================================================================ -->
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
<div class="lt-cmd-palette" id="lt-cmd-palette">
<div class="lt-cmd-input-wrap">
<span class="lt-cmd-prompt">&gt;</span>
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
placeholder="Search commands&hellip;" autocomplete="off"
spellcheck="false" aria-label="Search commands">
</div>
<div class="lt-cmd-results" id="lt-cmd-results">
<div class="lt-cmd-empty">Start typing to search&hellip;</div>
</div>
<div class="lt-cmd-footer">
<span><kbd>&#x2191;</kbd><kbd>&#x2193;</kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</div>
<!-- base.js + utils.js + globals already loaded in <head> via layout_header.php -->
<?php if (!empty($pageScripts)): ?>
<!-- PAGE-SPECIFIC SCRIPTS -->
<?php foreach ($pageScripts as $_ltf_script): ?>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="<?= htmlspecialchars($_ltf_script, ENT_QUOTES, 'UTF-8') ?>"></script>
<?php endforeach; ?>
<?php endif; ?>
<?php if (!empty($pageInlineScript)): ?>
<!-- PAGE INLINE SCRIPT -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
<?= $pageInlineScript ?>
</script>
<?php endif; ?>
<!-- LT INIT boot animation + global UI init (base.js handles keys/nav automatically) -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
if (window.lt) {
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
// Theme toggle button
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
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: '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(); } },
];
<?php if (!empty($GLOBALS['currentUser']['is_admin'])): ?>
_cpCmds = _cpCmds.concat([
{ id: 'admin-templates', group: 'Admin', icon: 'T', label: 'Templates', action: function() { window.location.href = '/admin/templates'; } },
{ id: 'admin-workflow', group: 'Admin', icon: 'W', label: 'Workflow', action: function() { window.location.href = '/admin/workflow'; } },
{ id: 'admin-recurring', group: 'Admin', icon: 'R', label: 'Recurring Tickets',action: function() { window.location.href = '/admin/recurring-tickets'; } },
{ id: 'admin-fields', group: 'Admin', icon: 'F', label: 'Custom Fields', action: function() { window.location.href = '/admin/custom-fields'; } },
{ id: 'admin-activity', group: 'Admin', icon: 'A', label: 'User Activity', action: function() { window.location.href = '/admin/user-activity'; } },
{ id: 'admin-audit', group: 'Admin', icon: 'L', label: 'Audit Log', action: function() { window.location.href = '/admin/audit-log'; } },
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
]);
<?php endif ?>
lt.cmdPalette.init(_cpCmds);
}
// 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;
});
};
});
}
// ── Notification Bell ─────────────────────────────────────────────
<?php if (!empty($GLOBALS['currentUser'])): ?>
(function() {
var bell = document.getElementById('lt-notif-bell');
var panel = document.getElementById('lt-notif-panel');
var list = document.getElementById('lt-notif-list');
var clearBtn = document.getElementById('lt-notif-clear-btn');
var wrapEl = document.getElementById('lt-notif-wrap');
if (!bell || !panel) return;
var _open = false;
function fmtTime(dateStr) {
var d = new Date(dateStr);
var diff = Math.floor((Date.now() - d) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400)return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function renderNotifications(data) {
lt.notif.set(bell, data.unread_count || 0);
if (!data.notifications || !data.notifications.length) {
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">No recent notifications</div>';
return;
}
list.innerHTML = data.notifications.map(function(n) {
return '<div class="lt-notif-item' + (n.is_read ? '' : ' lt-notif-item--unread') +
'" tabindex="0" role="link" data-url="' + esc(n.url) + '">' +
'<div class="lt-notif-dot' + (n.is_read ? ' lt-notif-dot--read' : '') + '"></div>' +
'<div class="lt-notif-item-body">' +
'<div class="lt-notif-item-title">' + esc(n.title) + '</div>' +
'<div class="lt-notif-item-time">' + fmtTime(n.created_at) + '</div>' +
'</div></div>';
}).join('');
list.querySelectorAll('.lt-notif-item').forEach(function(item) {
function go() { if (item.dataset.url) window.location.href = item.dataset.url; }
item.addEventListener('click', go);
item.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
});
}
function loadNotifications() {
fetch('/api/notifications.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(renderNotifications)
.catch(function() {
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
});
}
function openPanel() { _open = true; panel.removeAttribute('aria-hidden'); bell.setAttribute('aria-expanded','true'); loadNotifications(); }
function closePanel() { _open = false; panel.setAttribute('aria-hidden','true'); bell.setAttribute('aria-expanded','false'); }
bell.addEventListener('click', function(e) { e.stopPropagation(); _open ? closePanel() : openPanel(); });
if (clearBtn) {
clearBtn.addEventListener('click', function() {
fetch('/api/notifications.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ action: 'mark_read' })
}).then(loadNotifications);
});
}
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
// Initial badge count + poll every 60s
loadNotifications();
setInterval(loadNotifications, 60000);
})();
<?php endif ?>
// ── Avatar image error fallback (CSP blocks inline onerror) ──────
// Uses capture-phase error delegation: if an img inside .lt-avatar
// fails to load, add .lt-avatar-img-err to hide it (CSS display:none),
// revealing the initials span underneath.
document.addEventListener('error', function(e) {
if (e.target.tagName === 'IMG' && e.target.closest('.lt-avatar')) {
e.target.classList.add('lt-avatar-img-err');
}
}, true);
// Footer hint bar actions (keyboard help + settings — work on all pages)
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
if (action === 'show-keyboard-help') {
if (window.lt) lt.modal.open('lt-keys-help');
} else if (action === 'open-settings' || action === 'open-settings-modal') {
if (typeof openSettingsModal === 'function') {
openSettingsModal();
} else if (window.lt) {
lt.toast.info('Settings available on the Dashboard');
}
}
});
</script>
</body>
</html>
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* layout_header.php Shared top-of-page partial for all views.
*
* Expected variables set by the including view before require:
* string $pageTitle Page title suffix (e.g. "Dashboard", "Ticket #42")
* string $activeNav Active nav key: 'dashboard', 'tickets', 'admin-*'
* array|null $pageStyles Optional extra CSS hrefs to load
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
*
* Globals used:
* $GLOBALS['currentUser'] user array (username, display_name, is_admin, groups)
* $GLOBALS['config'] app config array
* CsrfMiddleware::getToken() returns current CSRF token string
*/
$_lt_user = $GLOBALS['currentUser'] ?? [];
$_lt_isAdmin = !empty($_lt_user['is_admin']);
$_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>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#030508">
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> &mdash; <?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<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&family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
<?php if (!empty($pageStyles)): ?>
<?php foreach ($pageStyles as $_lt_css): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
<?php endforeach; ?>
<?php endif; ?>
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
<!-- 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?v=<?= $_lt_assetVer ?>"></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 -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
window.APP_TIMEZONE = <?= json_encode($GLOBALS['config']['TIMEZONE'] ?? 'UTC', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
window.APP_TIMEZONE_ABBREV = <?= json_encode($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
window.APP_TIMEZONE_OFFSET = <?= (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0) ?>;
window.CURRENT_USER = <?= json_encode([
'id' => (int)($GLOBALS['currentUser']['user_id'] ?? 0),
'username'=> $GLOBALS['currentUser']['username'] ?? '',
'isAdmin' => !empty($GLOBALS['currentUser']['is_admin']),
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
</script>
</head>
<body>
<!-- SKIP LINK -->
<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 -->
<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>
</div>
<!-- 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 class="lt-nav-drawer-header">
<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">&#x2715;</button>
</div>
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
<a href="/"
class="lt-nav-drawer-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>Dashboard</a>
<?php if ($_lt_isAdmin): ?>
<div class="lt-nav-drawer-section">Admin</div>
<a href="/admin/templates" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-templates' ? ' active' : '' ?>">Templates</a>
<a href="/admin/workflow" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-workflow' ? ' active' : '' ?>">Workflow</a>
<a href="/admin/recurring-tickets" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-recurring' ? ' active' : '' ?>">Recurring</a>
<a href="/admin/custom-fields" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-custom-fields' ? ' active' : '' ?>">Custom Fields</a>
<a href="/admin/user-activity" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-user-activity' ? ' active' : '' ?>">User Activity</a>
<a href="/admin/audit-log" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-audit-log' ? ' active' : '' ?>">Audit Log</a>
<a href="/admin/api-keys" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-api-keys' ? ' active' : '' ?>">API Keys</a>
<?php endif; ?>
</nav>
</div><!-- /.lt-nav-drawer -->
<!-- Overlay: outside drawer, full-screen; JS toggles .open class -->
<div id="lt-nav-overlay" class="lt-nav-drawer-overlay"></div>
<!-- PRIMARY HEADER -->
<header class="lt-header" role="banner">
<div class="lt-header-left">
<!-- Hamburger opens mobile nav drawer -->
<button type="button"
class="lt-menu-btn"
id="lt-menu-btn"
data-action="open-nav-drawer"
aria-label="Open navigation menu"
aria-expanded="false"
aria-controls="lt-nav-drawer">
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
</button>
<!-- Brand -->
<div class="lt-brand">
<a href="/"
class="lt-brand-title lt-glitch"
data-text="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>"
style="text-decoration:none"
aria-label="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?> home"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></a>
<span class="lt-brand-subtitle"><?= htmlspecialchars($_lt_subtitle, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<!-- Desktop navigation -->
<nav class="lt-nav" aria-label="Main navigation">
<a href="/"
class="lt-nav-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>
Dashboard
</a>
<?php if ($_lt_isAdmin): ?>
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
<a href="#"
class="lt-nav-link<?= str_starts_with($_lt_navActive, 'admin') ? ' active' : '' ?>"
role="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="lt-admin-dropdown-menu">
Admin &#x25BE;
</a>
<ul class="lt-nav-dropdown-menu"
id="lt-admin-dropdown-menu"
role="menu"
aria-label="Admin menu">
<li role="none"><a href="/admin/templates" role="menuitem" class="<?= $_lt_navActive === 'admin-templates' ? 'active' : '' ?>">Templates</a></li>
<li role="none"><a href="/admin/workflow" role="menuitem" class="<?= $_lt_navActive === 'admin-workflow' ? 'active' : '' ?>">Workflow</a></li>
<li role="none"><a href="/admin/recurring-tickets" role="menuitem" class="<?= $_lt_navActive === 'admin-recurring' ? 'active' : '' ?>">Recurring</a></li>
<li role="none"><a href="/admin/custom-fields" role="menuitem" class="<?= $_lt_navActive === 'admin-custom-fields' ? 'active' : '' ?>">Custom Fields</a></li>
<li role="none"><a href="/admin/user-activity" role="menuitem" class="<?= $_lt_navActive === 'admin-user-activity' ? 'active' : '' ?>">User Activity</a></li>
<li role="none"><a href="/admin/audit-log" role="menuitem" class="<?= $_lt_navActive === 'admin-audit-log' ? 'active' : '' ?>">Audit Log</a></li>
<li role="none"><a href="/admin/api-keys" role="menuitem" class="<?= $_lt_navActive === 'admin-api-keys' ? 'active' : '' ?>">API Keys</a></li>
</ul>
</div>
<?php endif; ?>
</nav><!-- /.lt-nav -->
</div><!-- /.lt-header-left -->
<div class="lt-header-right">
<?php if (!empty($_lt_user)): ?>
<?php
$_lt_displayName = $_lt_user['display_name'] ?? $_lt_user['username'] ?? '';
$_lt_words = array_filter(explode(' ', $_lt_displayName));
$_lt_initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($_lt_words, 0, 2))));
$_lt_userId = (int)($_lt_user['user_id'] ?? 0);
$_lt_avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$_lt_avatarColor = $_lt_avatarColors[abs(crc32($_lt_displayName)) % count($_lt_avatarColors)];
?>
<div class="lt-avatar lt-avatar--sm <?= $_lt_avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?>">
<?php if ($_lt_userId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
alt=""
class="lt-avatar-img">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
</div>
<span class="lt-header-user"><?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($_lt_isAdmin): ?>
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
<?php endif; ?>
<?php endif; ?>
<!-- Notification Bell -->
<?php if (!empty($_lt_user)): ?>
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
<button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-wrap"
id="lt-notif-bell"
aria-label="Notifications"
aria-expanded="false"
aria-controls="lt-notif-panel"
title="Notifications">
&#x1F514;
</button>
<div class="lt-notif-panel" id="lt-notif-panel" aria-hidden="true" role="dialog" aria-label="Notifications">
<div class="lt-notif-panel-header">
<span>Notifications</span>
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button>
</div>
<div class="lt-notif-panel-list" id="lt-notif-list">
<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">
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
</div>
</div>
</div>
<?php endif; ?>
<button type="button" id="lt-cmd-trigger"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Command palette (Ctrl+K)"
aria-label="Open command palette"
onclick="if(window.lt&&lt.cmdPalette)lt.cmdPalette.open()"
style="font-size:0.65rem;opacity:0.65;letter-spacing:0.03em;padding:0.2rem 0.45rem">&#x2315;&nbsp;K</button>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Switch to light mode" title="Switch to light mode">&#x2600;</button>
</div><!-- /.lt-header-right -->
</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=none'; } },
{ 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);
});
// 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>
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">