Compare commits

...
Author SHA1 Message Date
jared cabdae35cc Merge development into main: Custom Fields wiring (#47)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 14s
Lint / PHP requirements (version + extensions) (push) Successful in 53s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m43s
Lint / Deploy (push) Successful in 4s
- Wire Custom Fields into ticket creation and viewing (#47)
2026-09-11 13:17:33 -04:00
jaredandClaude Sonnet 5 3266373cdf Wire Custom Fields into ticket creation and viewing (#47)
Lint / PHP (phpcs PSR-12) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m24s
Lint / Deploy (push) Successful in 3s
CustomFieldModel::setValue()/setValues()/getValuesForTicket() were
never called anywhere outside api/custom_fields.php's own admin CRUD
for field *definitions* — CreateTicketView.php never rendered custom
fields, TicketController::create() never collected or saved them, and
TicketView.php never displayed them. The whole feature (admin defines
fields at /admin/custom-fields, including marking them Required) was
config-only with zero consumer; is_required was enforced nowhere.

Added:
- api/ticket_custom_fields.php: new endpoint (bootstrap.php-based, so
  any ticket editor can use it, not just admins) that saves values for
  a ticket. Only considers fields applicable to the ticket's current
  category (or category-less fields); enforces is_required, validates
  select values against the field's configured options, and validates
  number fields are numeric. Values for fields that don't apply are
  silently ignored rather than persisted, so a value typed before a
  category change can't linger as orphaned data.
- CreateTicketView.php: renders every active field definition (all
  categories, since the ticket doesn't exist yet), grouped with a
  data-custom-field-category attribute and toggled client-side as the
  Category select changes, matching the existing visibility-groups
  toggle pattern. Submitted as part of the same form.
- TicketController::create(): validates is_required server-side against
  the fields applicable to the *submitted* category (not just whatever
  was visible client-side) before creating the ticket, then persists
  via CustomFieldModel::setValues() after a successful create.
- TicketView.php: new "Custom Fields" tab (only shown when the ticket's
  category has applicable fields) rendering current values with a
  single Save button, calling the new endpoint — a panel-plus-save
  interaction rather than per-field inline auto-save, to keep scope
  contained across 6 field types.

Verified against real MariaDB and a real running server: a required
field left blank is correctly rejected (both at ticket-creation time
and when editing an existing ticket) with no partial write; a valid
submission persists exactly the fields applicable to that ticket's
category; an invalid select value is rejected without touching
previously-saved values; and each rejection correctly returns a
rotated recovery csrf_token (initially missed on the validation-error
paths, since they used a plain echo/exit instead of the bootstrap.php
apiRespond() helper every other endpoint's error paths use for this).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:12:42 -04:00
jared f342e446d3 Merge development into main: bulk-op notification/audit fixes + workflow-validated auto-reopen (#67, #68, #74)
Lint / PHP (phpcs PSR-12) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 2s
- Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
- Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
2026-09-11 12:44:06 -04:00
jaredandClaude Sonnet 5 18c213ebd7 Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
Lint / PHP (phpcs PSR-12) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 14s
Lint / PHP requirements (version + extensions) (push) Successful in 34s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m25s
Lint / Deploy (push) Successful in 8s
create_ticket_api.php's dedup-reopen path wrote status = 'Open' via a
raw SQL UPDATE, completely bypassing TicketModel::updateTicket() and
WorkflowModel::isTransitionAllowed() — the one status-write path in
the app that never consulted the workflow engine at all. If an admin
configured the Workflow Designer to disallow a direct Closed->Open
transition, this automated path still forced it unconditionally.

Now checks isTransitionAllowed('Closed', 'Open', false) first (false
since this is an unattended system account, not admin-elevated). If
not allowed, falls back to any transition the Workflow Designer does
allow from Closed that requires neither a comment nor admin privilege
(both of which this unattended automation can't satisfy), and applies
it via TicketModel::updateTicket() instead of raw SQL. If no such
transition exists at all, the ticket is deliberately left Closed
(rather than forcing an unconfigured state) with a comment and audit
entry explaining why, so the recurrence is still visible to a human
without silently violating workflow rules.

Verified against real MariaDB across all three branches: direct
Closed->Open allowed (reopens to Open), disallowed but Closed->'In
Progress' available unattended (falls back correctly), and no usable
transition configured at all (ticket correctly stays Closed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:46 -04:00
jaredandClaude Sonnet 5 6adbb29964 Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
BulkOperationsModel's bulk_close/bulk_status paths had zero references
to NotificationHelper — the exact same status transition (e.g.
Open->Closed) silently produced no Matrix/watcher notification when
performed via bulk actions, while the single-ticket edit page and
Bearer API both notify on every status change. Separately, their
audit_log entries used a bare ['status' => 'Closed', ...] shape
instead of the {'status': {'from': X, 'to': Y}} shape every other
status-change path uses, which broke two downstream consumers:
TicketView.php's timeline fell back to a generic "updated this
ticket" instead of "updated status", and notifications.php's
$details['status']['from'] on a string produced a broken "? -> ?"
notification title.

Fixed the audit_log shape for both operation types, and added a
notification queue collected during the per-ticket loop and flushed
only after a successful commit (so atomic-mode rollback correctly
sends zero notifications, matching how nothing else about a rolled-
back batch takes effect either). Also fixed an incidental bug found
while matching this to the single-ticket path: update_ticket.php's
notifyWatchers() call never passed the ticket's visibility, silently
defaulting to 'public' and always including the shared notify list
even for confidential/internal tickets — the exact leak #71 fixed
elsewhere in NotificationHelper itself, just never reaching this
call site.

Verified against real MariaDB with a real local webhook-capturing
server: bulk_close correctly fires sendStatusChangeNotification() +
notifyWatchers() with the right old/new status and a redacted title
for a confidential ticket; audit_log rows show the correct {from,to}
shape; and an atomic-mode rollback (one ticket's transition invalid)
sends zero notifications and leaves both tickets unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:37 -04:00
jared 5a69c41f48 Merge development into main: error-handling rollout + session privilege re-sync (#38, #39, #56, #105)
Lint / PHP (phpcs PSR-12) (push) Successful in 44s
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m49s
Lint / Deploy (push) Successful in 2s
- Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
- Periodically re-sync session privileges from Authelia (#56)
2026-09-11 12:23:49 -04:00
jaredandClaude Sonnet 5 6b7e67eee4 Periodically re-sync session privileges from Authelia (#56)
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 3s
AuthMiddleware::authenticate() only re-read Remote-User/Remote-Groups
(and thus is_admin, via UserModel::syncUserFromAuthelia) when
$_SESSION['user'] didn't exist yet. Once a session existed, every
subsequent request only checked the idle timer — never re-validating
against current Authelia/LLDAP state. An admin's group membership
revoked in LLDAP, or a logout at the Authelia proxy, left their
already-open session with full access for up to SESSION_TIMEOUT (5h
default), with no way to force early revocation short of clearing the
server-side session store.

Added PRIVILEGE_RESYNC_INTERVAL (default 5 min, matching
UserModel's own cache TTL) and a resyncPrivileges() check on every
already-authenticated request past that interval: re-reads the current
request's forward-auth headers (enforcing the trusted-proxy check
again, same as a fresh login), and either destroys the session and
redirects to re-auth if the user no longer has any required group, or
re-syncs is_admin/groups/display_name/email if they do. Best-effort if
this particular request doesn't carry forward-auth headers at all
(skips silently rather than force-logging out, retried next interval).

UserModel::syncUserFromAuthelia() has its own 5-minute in-process
cache keyed only by username (not by the groups being synced), so a
naive re-call during a resync would have kept returning the
pre-revocation cached result for up to 5 more minutes — invalidated
that cache entry immediately beforehand to guarantee a real re-sync.

Verified against real MariaDB across a fresh login, a same-interval
request confirming no premature resync, an admin-privilege-revocation
mid-session (is_admin flips to false in both session and DB, verified
via a direct query), and a full group-membership revocation (session
destroyed, redirected to re-auth, confirmed the request never reaches
past that point).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:17:27 -04:00
jaredandClaude Sonnet 5 71bf64c1e2 Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.

Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.

Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.

For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.

Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:17:15 -04:00
33 changed files with 630 additions and 72 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -10,8 +10,8 @@
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -1,5 +1,8 @@
<?php
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
header('Content-Type: application/json');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -6,6 +6,9 @@
* Serves file downloads for ticket attachments
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -8,8 +8,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -11,6 +11,9 @@
* - 503 Service Unavailable: System has issues
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -18,8 +18,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* Save custom field values for a ticket.
*
* POST { ticket_id, values: { [field_id]: value, ... } }
*
* Only fields applicable to the ticket's current category (or category-less
* fields) are considered; anything else in `values` is ignored rather than
* persisted, so a value typed for a field that no longer applies (e.g. the
* category changed) can't linger as orphaned/misleading data.
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
}
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
if ($ticketId === '') {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'ticket_id required']);
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
apiRespond(['success' => false, 'error' => 'Ticket not found']);
}
$fieldModel = new CustomFieldModel($conn);
$definitions = $fieldModel->getAllDefinitions($ticket['category'], true);
$errors = [];
$toSave = [];
foreach ($definitions as $def) {
$fieldId = (int)$def['field_id'];
$raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null);
if ($def['field_type'] === 'checkbox') {
$normalized = !empty($raw) ? '1' : '0';
} else {
$normalized = is_scalar($raw) ? trim((string)$raw) : '';
}
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$errors[] = $def['field_label'] . ' is required';
continue;
}
if ($def['field_type'] === 'select' && $normalized !== '') {
$allowedOptions = $def['field_options']['options'] ?? [];
if (!in_array($normalized, $allowedOptions, true)) {
$errors[] = $def['field_label'] . ' has an invalid selection';
continue;
}
}
if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) {
$errors[] = $def['field_label'] . ' must be a number';
continue;
}
$toSave[$fieldId] = $normalized;
}
if (!empty($errors)) {
http_response_code(422);
apiRespond(['success' => false, 'error' => implode('; ', $errors)]);
}
$fieldModel->setValues($ticketId, $toSave);
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [
'reason' => 'custom fields updated',
]);
apiRespond(['success' => true]);
+2 -2
View File
@@ -22,8 +22,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -14,8 +14,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+4 -3
View File
@@ -1,8 +1,8 @@
<?php
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -276,7 +276,8 @@ try {
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
+2 -2
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -11,8 +11,8 @@
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+5
View File
@@ -106,6 +106,11 @@ $GLOBALS['config'] = [
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
// How often an already-logged-in session re-validates Remote-User/
// Remote-Groups against current Authelia/LLDAP state (AuthMiddleware).
// Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT.
'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes
// CSRF settings
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
+52
View File
@@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
@@ -18,6 +19,7 @@ class TicketController
private $userModel;
private $workflowModel;
private $templateModel;
private $customFieldModel;
private $conn;
public function __construct($conn)
@@ -29,6 +31,7 @@ class TicketController
$this->userModel = new UserModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->templateModel = new TemplateModel($conn);
$this->customFieldModel = new CustomFieldModel($conn);
}
public function view($id)
@@ -60,6 +63,11 @@ class TicketController
// Get allowed status transitions for this ticket
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
// Custom fields applicable to this ticket's category, with any
// already-saved values for it
$customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true);
$customFieldValues = $this->customFieldModel->getValuesForTicket($id);
// Make $conn available to view for visibility groups
$conn = $this->conn;
@@ -73,6 +81,12 @@ class TicketController
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// All active custom field definitions (every category, plus
// category-less ones) — the create form renders them all and toggles
// visibility client-side as the Category select changes, since the
// ticket doesn't exist yet to scope the query to one category.
$allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true);
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
@@ -131,6 +145,38 @@ class TicketController
return;
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$error = $def['field_label'] . ' is required';
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
@@ -144,6 +190,12 @@ class TicketController
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Persist custom field values for the fields applicable to
// this ticket's category
if (!empty($customFieldsToSave)) {
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+72 -25
View File
@@ -2,8 +2,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once __DIR__ . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -42,6 +42,8 @@ try {
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/models/StatsModel.php';
require_once __DIR__ . '/models/TicketModel.php';
require_once __DIR__ . '/models/WorkflowModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn);
@@ -338,17 +340,52 @@ if ($existing) {
exit;
}
// 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();
// Ticket was closed — reopen it and add a recurrence comment. Route
// through the Workflow Designer like every other status-write path in
// the app, rather than forcing status='Open' via raw SQL regardless of
// configured transition rules.
$workflowModel = new WorkflowModel($conn);
$reopenStatus = 'Open';
if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) {
// Direct Closed->Open isn't configured — fall back to any transition
// the Workflow Designer does allow from Closed that this unattended,
// non-admin automation can actually satisfy (no comment prompt, no
// admin elevation). If even that doesn't exist, leave the ticket
// Closed rather than force an unconfigured state.
$reopenStatus = null;
foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) {
if (!$transition['requires_comment'] && !$transition['requires_admin']) {
$reopenStatus = $transition['to_status'];
break;
}
}
}
if ($reopenStatus !== null) {
$ticketModel = new TicketModel($conn);
$ticketModel->updateTicket([
'ticket_id' => $existingId,
'title' => $title,
'description' => $description,
'category' => $category,
'type' => $type,
'status' => $reopenStatus,
'priority' => $priority,
], $userId);
} else {
error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId"
. "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed");
}
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"hwmonDaemon detected this condition again. The ticket description reflects the "
. "original report; see this comment's timestamp for when the issue recurred.";
if ($reopenStatus === null) {
$commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n"
. "hwmonDaemon detected this condition again. No Workflow Designer transition from "
. "Closed is configured that this automation can perform unattended (no comment/admin "
. "requirement); the ticket remains Closed. Please review and reopen manually if appropriate.";
}
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -356,30 +393,40 @@ if ($existing) {
$commentStmt->execute();
$commentStmt->close();
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
if ($reopenStatus !== null) {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
// Ticket reopened — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
} else {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed',
]);
}
Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
], 'automated');
if ($reopenStatus !== null) {
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => $reopenStatus,
], 'automated');
}
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
'message' => $reopenStatus !== null
? 'Existing closed ticket reopened'
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
]);
exit;
}
+26 -5
View File
@@ -10,26 +10,36 @@ class ErrorHandler
{
private static ?string $logFile = null;
private static bool $initialized = false;
private static string $responseMode = 'json';
/**
* Initialize error handling
*
* @param bool $displayErrors Whether to display errors (false in production)
* @param bool $displayErrors Whether to display errors (false in production)
* @param string $responseMode 'json' (API endpoints) or 'html' (page views
* renders views/error_500.php instead of a JSON body)
*/
public static function init(bool $displayErrors = false): void
public static function init(bool $displayErrors = false, string $responseMode = 'json'): void
{
if (self::$initialized) {
return;
}
self::$responseMode = $responseMode;
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', $displayErrors ? '1' : '0');
ini_set('log_errors', '1');
// Set up log file
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
ini_set('error_log', self::$logFile);
// Deliberately does NOT override the 'error_log' ini setting: doing so
// used to redirect every error_log() call in the request to a fixed
// /tmp file, silently diverting logs away from wherever the server is
// actually configured to send them (php-fpm's error_log, stdout in a
// container, etc.) the moment this got wired into more than one
// endpoint. self::$logFile / getRecentErrors() are unused (no callers
// app-wide) and exist only as an opt-in helper if something later
// wants a dedicated log file.
// Register handlers
set_error_handler([self::class, 'handleError']);
@@ -151,6 +161,17 @@ class ErrorHandler
{
http_response_code($httpCode);
if (self::$responseMode === 'html') {
if (!headers_sent()) {
header('Content-Type: text/html; charset=utf-8');
}
// Deliberately not passed $message/$exception — see error_500.php's
// docblock on why the fatal-error page must render with zero
// dependency on request-specific state.
include dirname(__DIR__) . '/views/error_500.php';
exit;
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
+7
View File
@@ -1,6 +1,13 @@
<?php
// Main entry point for the application
// Registered first, before anything else, so a genuine fatal anywhere below
// (including during config.php's own env parsing) renders the styled 500
// page instead of falling through to PHP's raw default error handling.
require_once 'helpers/ErrorHandler.php';
ErrorHandler::init(false, 'html');
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php';
+72
View File
@@ -92,6 +92,19 @@ class AuthMiddleware
} else {
// Update last activity time
$_SESSION['last_activity'] = time();
// Periodically re-validate Remote-User/Remote-Groups against
// current Authelia/LLDAP state, so a revoked admin (or anyone
// dropped from the required groups) loses access promptly
// instead of keeping it for up to SESSION_TIMEOUT. Only the
// idle timer was checked above; nothing previously re-read
// these headers once a session already existed.
$resyncInterval = $GLOBALS['config']['PRIVILEGE_RESYNC_INTERVAL'] ?? 300;
$lastSync = $_SESSION['last_privilege_sync'] ?? 0;
if (time() - $lastSync > $resyncInterval) {
$this->resyncPrivileges();
}
return $_SESSION['user'];
}
}
@@ -134,6 +147,7 @@ class AuthMiddleware
// Store user in session
$_SESSION['user'] = $user;
$_SESSION['last_activity'] = time();
$_SESSION['last_privilege_sync'] = time();
// Generate new CSRF token on login
require_once __DIR__ . '/CsrfMiddleware.php';
@@ -142,6 +156,64 @@ class AuthMiddleware
return $user;
}
/**
* Re-validate the current session's Remote-User/Remote-Groups against
* this request's forward-auth headers, and re-sync or revoke access on
* mismatch. Called periodically (PRIVILEGE_RESYNC_INTERVAL) from an
* already-authenticated session see authenticate().
*
* Best-effort: if this particular request doesn't carry forward-auth
* headers at all (e.g. a proxy hiccup), the session is left as-is rather
* than force-logging the user out, and the check is simply retried on
* the next request past the interval.
*/
private function resyncPrivileges(): void
{
$username = $this->getHeader('HTTP_REMOTE_USER');
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
if (empty($username)) {
return;
}
$this->enforceTrustedProxy();
// A different Remote-User than the session's own means Authelia is
// now asserting a different identity entirely for this proxy path;
// don't silently relabel the session as that other user.
if ($username !== ($_SESSION['user']['username'] ?? null)) {
return;
}
if (!$this->checkGroupAccess($groups)) {
$this->logSecurityEvent('privilege_resync_revoked', [
'username' => $username,
'groups' => $groups ?: 'none',
]);
session_unset();
session_destroy();
$this->redirectToAuth();
exit;
}
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
// Bypass UserModel's 5-minute in-process cache — that cache key isn't
// group-aware, so a stale cached hit here would silently keep serving
// the pre-revocation is_admin value for the rest of the cache's TTL.
UserModel::invalidateCache(null, $username);
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
$wasAdmin = !empty($_SESSION['user']['is_admin']);
if ($wasAdmin && empty($user['is_admin'])) {
$this->logSecurityEvent('privilege_resync_admin_revoked', ['username' => $username]);
}
$_SESSION['user'] = $user;
$_SESSION['last_privilege_sync'] = time();
}
/**
* Reject forward-auth headers that did not arrive via a trusted proxy.
*
+61 -2
View File
@@ -125,13 +125,23 @@ class BulkOperationsModel
$processed = 0;
$failed = 0;
$errors = [];
// Status-change notifications collected during the loop below and
// sent only after a successful commit, matching how the single-ticket
// and Bearer API paths never notify for a change that didn't durably
// land (and how an atomic-mode rollback must not fire any at all).
$notificationQueue = [];
// Load required models
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';
$ticketModel = new TicketModel($this->conn);
$auditLogModel = new AuditLogModel($this->conn);
$userModel = new UserModel($this->conn);
$actor = $operation['performed_by'] ? $userModel->getUserById((int)$operation['performed_by']) : null;
$changedByDisplay = $actor['display_name'] ?? $actor['username'] ?? null;
// Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
@@ -221,8 +231,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => 'Closed'],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => 'Closed',
];
}
}
break;
@@ -291,8 +311,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => $parameters['status']],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => $parameters['status'],
];
}
}
}
@@ -364,6 +394,35 @@ class BulkOperationsModel
@unlink($path);
}
}
// Fire the same Matrix/watcher notifications the single-ticket and
// Bearer API status-change paths send, now that every change in
// this batch is durably committed. Best-effort: a notification
// failure must never turn an otherwise-successful bulk operation
// into an error.
foreach ($notificationQueue as $n) {
try {
NotificationHelper::sendStatusChangeNotification(
$n['ticketId'],
$n['oldStatus'],
$n['newStatus'],
$n['title'],
$changedByDisplay,
$n['visibility']
);
NotificationHelper::notifyWatchers(
$this->conn,
$n['ticketId'],
$n['title'],
'status_changed',
['old_status' => $n['oldStatus'], 'new_status' => $n['newStatus'], 'changed_by' => $changedByDisplay],
(int)$operation['performed_by'],
$n['visibility']
);
} catch (Throwable $e) {
error_log("Bulk operation $operationId: notification failed for ticket {$n['ticketId']}: " . $e->getMessage());
}
}
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
+61 -1
View File
@@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<select id="category" name="category" class="lt-select" data-action="toggle-custom-fields">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
@@ -211,6 +211,55 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<?php if (!empty($allCustomFieldDefs)) : ?>
<!-- ── SECTION 5b: Custom Fields ─────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Additional Fields</div>
<div class="lt-section-body">
<?php foreach ($allCustomFieldDefs as $cfDef) : ?>
<div class="lt-form-group custom-field-group"
data-custom-field-category="<?= htmlspecialchars($cfDef['category'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
<?php
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'custom_field_' . (int)$cfDef['field_id'];
?>
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<option value=""> Select </option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php endif ?>
</div>
<?php endforeach ?>
<p class="lt-form-hint">Fields shown depend on the selected Category.</p>
</div>
</div>
<?php endif ?>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
@@ -316,6 +365,15 @@ include __DIR__ . '/layout_header.php';
.catch(function () { /* silent — duplicate check is non-critical */ });
}
// ── Custom fields: show only the selected category's fields ──
function toggleCustomFields() {
var category = document.getElementById('category').value;
document.querySelectorAll('.custom-field-group').forEach(function (group) {
var fieldCategory = group.getAttribute('data-custom-field-category');
group.classList.toggle('is-hidden', fieldCategory !== '' && fieldCategory !== category);
});
}
// ── Visibility groups toggle ──────────────────────────────
var visibilityHints = {
'public': 'Everyone who is logged in can view this ticket.',
@@ -387,9 +445,11 @@ include __DIR__ . '/layout_header.php';
switch (target.getAttribute('data-action')) {
case 'load-template': loadTemplate(); break;
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
case 'toggle-custom-fields': toggleCustomFields(); break;
}
});
toggleCustomFields();
if (window.lt) lt.keys.initDefaults();
}());
</script>
+100
View File
@@ -397,6 +397,12 @@ document.addEventListener('DOMContentLoaded', function() {
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
Dependencies
</button>
<?php if (!empty($customFieldDefs)) : ?>
<button type="button" class="lt-tab" id="custom-fields-tab-btn"
role="tab" data-tab="custom-fields-panel" aria-selected="false" aria-controls="custom-fields-panel">
Custom Fields
</button>
<?php endif ?>
<button type="button" class="lt-tab" id="activity-tab-btn"
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
Activity
@@ -682,6 +688,60 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<?php if (!empty($customFieldDefs)) : ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: CUSTOM FIELDS
═══════════════════════════════════════════════════════════ -->
<div id="custom-fields-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="custom-fields-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Custom Fields</div>
<div class="lt-section-body">
<div id="customFieldsMsg" class="lt-msg is-hidden lt-mb-md" role="alert" aria-live="polite"></div>
<?php foreach ($customFieldDefs as $cfDef) :
$cfValue = $customFieldValues[$cfDef['field_name']]['field_value'] ?? '';
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'ticket_custom_field_' . (int)$cfDef['field_id'];
?>
<div class="lt-form-group">
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
><?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select">
<option value=""> Select </option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"
<?= $opt === $cfValue ? 'selected' : '' ?>><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1"
<?= $cfValue === '1' ? 'checked' : '' ?>>
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php endif ?>
</div>
<?php endforeach ?>
<button type="button" id="saveCustomFieldsBtn" class="lt-btn lt-btn-primary lt-btn-sm">SAVE CUSTOM FIELDS</button>
</div>
</div>
</div>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ACTIVITY
═══════════════════════════════════════════════════════════ -->
@@ -1013,6 +1073,46 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Save custom fields button
var saveCustomFieldsBtn = document.getElementById('saveCustomFieldsBtn');
if (saveCustomFieldsBtn) {
saveCustomFieldsBtn.addEventListener('click', function () {
var panel = document.getElementById('custom-fields-panel');
var msg = document.getElementById('customFieldsMsg');
var values = {};
panel.querySelectorAll('[name^="custom_fields["]').forEach(function (el) {
var m = el.name.match(/custom_fields\[(\d+)\]/);
if (!m) return;
var fieldId = m[1];
if (el.type === 'checkbox') {
values[fieldId] = el.checked ? '1' : '0';
} else {
values[fieldId] = el.value;
}
});
saveCustomFieldsBtn.disabled = true;
msg.classList.add('is-hidden');
lt.api.post('/api/ticket_custom_fields.php', {
ticket_id: window.ticketData.id,
values: values
}).then(function (data) {
saveCustomFieldsBtn.disabled = false;
if (data.success) {
lt.toast.success('Custom fields saved', 3000);
} else {
msg.textContent = data.error || 'Failed to save custom fields';
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
}
}).catch(function (error) {
saveCustomFieldsBtn.disabled = false;
msg.textContent = 'Failed to save custom fields: ' + error.message;
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
});
});
}
// Settings save/cancel
// Load user preference toggles on settings modal open
(function() {
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Standalone 500/fatal-error page, rendered by ErrorHandler for page-view
* (non-API) requests.
*
* Deliberately self-contained: a genuine fatal can happen before config.php
* finishes loading, mid-session-start, or mid-DB-query, so this view must
* not depend on $GLOBALS['config'], $GLOBALS['currentUser'], a session, or a
* DB connection being available/working. It links the static base.css
* stylesheet (served directly by the webserver, independent of PHP) to
* match the app's look without going through layout_header.php's app-state
* dependent setup.
*/
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 &mdash; Something Went Wrong</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/assets/css/base.css">
</head>
<body>
<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">[ 500 ] SOMETHING WENT WRONG</div>
<div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">
An unexpected error occurred. It's been logged; please try again shortly.
</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
</body>
</html>