Compare commits

...
Author SHA1 Message Date
jared 4fd2c7ce7d Merge #20 modal dismissal fix into main
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m32s
Lint / Deploy (push) Successful in 3s
2026-08-07 23:07:14 -04:00
jared 2ff7345a73 Dismissing the required-comment modal no longer looks like a close (#20)
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 41s
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 2s
A modal can be dismissed four ways: the ✕ button, Cancel, a backdrop click, or
Escape. base.js handles the last two globally (a document click handler and
registerKey('escape', closeAllModals)), so the status-change modal — which wired
only the two buttons — never learned it had been dismissed. The status dropdown
kept displaying the new status even though update_ticket.php was never called,
so the ticket looked closed with no comment until a reload showed it still open.

The same gap left every dynamically-inserted modal in the DOM when dismissed
that way, so the next open inserted a duplicate id that shadowed the live one.

- base.js closeModal now dispatches a bubbling lt:modalclose event (synced to
  web_template as bbec859), and _statusCommentModal treats it as "no comment".
- ticket.js reverts the dropdown on any dismissal, guarded against the re-entry
  its own lt.modal.close() would otherwise cause.
- dashboard.js gains openModalWithDismiss() so all seven dynamic modals plus the
  generic prompt modal tear down however they are dismissed.

Verified in headless chromium against all four dismissal routes plus a
confirm-with-comment control: 22/22. Against the pre-fix files the same test
fails 6 assertions — backdrop and Escape leave the dropdown on "Closed *" with
an orphaned overlay — so it reproduces the reported behaviour exactly.
2026-08-07 23:07:06 -04:00
jared 1de04d4908 Clear the markdown live preview after posting a comment
Setting the textarea's .value programmatically does not fire an 'input' event,
so updatePreview() never ran and the preview kept showing the just-posted
comment's rendered markdown underneath an empty composer.

(This change was already present in the working tree at the start of the
session; committing it on its own rather than folding it into an unrelated fix.)
2026-08-07 23:06:36 -04:00
jared 153f9a7cef Merge #23 light-mode ticket preview fix into main
Lint / PHP (phpcs PSR-12) (push) Successful in 1m9s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m54s
Lint / Deploy (push) Successful in 6s
2026-08-07 22:56:41 -04:00
jared 0a7201d754 Light mode: ticket-ID hover preview follows the theme (#23)
Lint / PHP (phpcs PSR-12) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m15s
Lint / Deploy (push) Successful in 2s
.ticket-preview-popup used var(--lt-surface), which is not defined anywhere, so
the background always fell through to the hardcoded #0a0e14. In light mode that
left a near-black panel — and since the rule set no `color`, the inherited
near-black body text was effectively invisible on it. The border was hardcoded
neon green and the shadow a heavy rgba(0,0,0,0.5).

Now uses --bg-card / --text-primary / --accent-green-border / --shadow-color,
and .preview-id uses --accent-cyan instead of the undefined --lt-cyan.

base.css gains the two tokens the light theme was missing (--accent-green-border
and --shadow-color), synced from web_template 0d633bd.

Verified with computed styles in headless chromium: light body-text contrast on
the panel goes from invisible to 17.7:1, dark stays at 13.2:1, and the ID accent
clears 3:1 in both themes.
2026-08-07 22:54:51 -04:00
jared 12ffd217bb Merge #19 light-mode status dropdown fix into main
Lint / PHP (phpcs PSR-12) (push) Successful in 29s
Lint / JS (eslint) (push) Successful in 17s
Lint / PHP requirements (version + extensions) (push) Successful in 40s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m14s
Lint / Deploy (push) Successful in 3s
2026-08-07 22:51:22 -04:00
jared a5b0655623 Light mode: status dropdown no longer renders dark (#19)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m11s
Lint / Deploy (push) Successful in 2s
Two separate causes, both light-mode-only:

1. base.css `.lt-select` sets `color-scheme: dark` on the element itself, which
   outranks the `color-scheme: light` the light theme sets on <html>, so the
   native dropdown popup kept dark chrome. The option list is also hardcoded
   #0d1117/#c9d1d9 with no light override. Fixed with light overrides for both
   (synced from web_template, where the same fix landed as 378a8cd).

2. ticket.css coloured the status select with var(--lt-success), --lt-amber,
   --lt-cyan and --lt-danger — none of which are defined anywhere in the
   project, so all four always fell through to hardcoded neon fallbacks. Now
   uses the --accent-* tokens, which carry the same hues and are redefined for
   light mode. The selectors also lead with .lt-select: at two classes they lost
   to base.css's `html[data-theme="light"] .lt-select` (0,2,1) and every status
   was repainted near-black in light mode.

Verified with computed styles in headless chromium — all four statuses in both
themes (8/8), plus the popup colour-scheme and option colours.
2026-08-07 22:51:16 -04:00
jared fa5f347c08 Merge #21 workflow enforcement for bulk operations into main
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 40s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m13s
Lint / Deploy (push) Successful in 3s
2026-08-07 22:45:34 -04:00
jared 1d03800ab2 Widen bulk_operations.status so partial bulk results can be recorded (#21)
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 3s
Found while verifying #21 against the live schema: the model writes
'completed_with_errors' (21 chars) when a bulk operation finishes with
per-ticket failures, but bulk_operations.status was varchar(20), so the
write failed with "Data too long for column 'status'".

This was latent — bulk status changes previously forced every transition
through, so failed was always 0. Now that they honour the Workflow
Designer, a partially-skipped batch is a normal outcome and hits it.

- migrations/001 widens the column to varchar(32) (idempotent).
- The baseline is updated to match, for fresh installs.
- The bookkeeping UPDATE is wrapped in a try/catch: it runs after the
  ticket changes are committed, so an instance deployed ahead of its
  migrations must not turn a completed operation into an error response.

Verified against the live database with a disposable-ticket harness:
comment-required rejection changes nothing, undefined transitions are
refused per ticket with a reason, allowed transitions still work, mixed
batches apply the valid half, and an already-Closed ticket is a no-op.
2026-08-07 22:40:48 -04:00
jared 9d982ab73f Bulk status/close: enforce Workflow Designer rules (#21)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m15s
Lint / Deploy (push) Successful in 2s
Bulk status changes previously bypassed the workflow entirely — the model
carried an explicit "admin-only escape hatch" note — so bulk edit could
drive tickets through transitions the designer forbids and skip comments
the designer requires.

BulkOperationsModel now applies the same rules as the single-ticket path:

- Transitions absent from status_transitions are refused per ticket and
  reported with a reason, instead of being forced through.
- requires_comment is checked up front across the whole selection, so a
  batch is rejected before any ticket is mutated rather than half-applied.
- The reason is persisted as a comment on each ticket changed, matching
  what a single-ticket close records.
- Tickets already in the target status are a no-op success, not a failure.

requires_admin needs no extra check: api/bulk_operation.php already gates
the endpoint on admin.

Client: both bulk modals now collect a reason, the close path gets a real
modal instead of a bare confirm, and per-ticket skip reasons surface in
the result toast instead of a bare failure count.
2026-08-07 22:35:36 -04:00
jared f57b472211 Merge pull request 'Bearer API extension: list/read/comment/close + key scopes' (#26) from development into main
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 18s
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
2026-07-15 19:19:24 -04:00
jaredandClaude Opus 4.8 d81fdf4104 Docs: document the Bearer API (endpoints, scopes) in README + admin page
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 28s
Security / PHP Security (semgrep) (push) Successful in 1m23s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 19s
Lint / JS (eslint) (pull_request) Successful in 6s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 23s
Security / PHP Security (semgrep) (pull_request) Successful in 2m36s
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
- README: Bearer API table (list/read/comment/status), scope explanation,
  and the new endpoints in the API Endpoints table.
- /admin/api-keys API Usage section: scopes note + copy-paste cURL examples
  for create, list/triage, read-one, comment, and close (uses APP_DOMAIN).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:16:42 -04:00
jaredandClaude Opus 4.8 d46f8ffd77 Add Bearer API: list/read tickets, post comments, change status
Lint / PHP (phpcs PSR-12) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 44s
Security / PHP Security (semgrep) (push) Successful in 2m47s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Extends the Bearer-key API beyond create-only (all rate-limited, scope-
enforced, per-key-label attribution):
- GET /api/tickets_api.php: triage the queue (status/priority/host title
  match + pagination) or read one ticket + its comments. read scope.
- POST /api/ticket_comment_api.php: post a comment as the key (user_name =
  key name, linked to the key owner). read_write scope.
- POST /api/ticket_status_api.php: change/close status with workflow
  validation + requires_comment; posts the close reason in the same call,
  fires the Matrix status notification, invalidates stats. read_write scope.

Reuses TicketModel/CommentModel/WorkflowModel/NotificationHelper; a read
key cannot mutate. Reachability requires the reverse-proxy Authelia bypass
(handled separately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:40:52 -04:00
jaredandClaude Opus 4.8 5cf5aa9591 API keys: add read/read_write scopes + admin scope selector & pagination
Foundation for extending the Bearer API beyond create-only:
- api_keys gains a scope column (read | read_write); baseline schema updated
  and the column applied to the live DB. Existing keys default to
  read_write so the hwmon create key keeps working.
- ApiKeyModel: createKey() takes a validated scope; validateKey() always
  surfaces scope (defaults read_write); getAllKeys() is paginated
  ({keys,total,page,perPage}, key_hash stripped).
- ApiKeyAuth: expose getKeyContext() (scope/key_name/created_by/api_key_id)
  and requireScope() (403 on insufficient scope); existing return values
  unchanged.
- create_ticket_api.php: require read_write scope (a read key can't create).
- Admin /admin/api-keys: scope selector on the create form, a scope column,
  and pagination (revoked keys were stacking up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:24:50 -04:00
20 changed files with 1086 additions and 67 deletions
+15 -1
View File
@@ -94,11 +94,22 @@ The following features are intentionally **not planned** for this system:
- **Required Fields**: Mark fields as required for validation
### API Key Management
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
- **Expiration**: Optional expiration dates for keys
- **Revocation**: Revoke compromised keys instantly
### Bearer API (automation / triage)
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
| Endpoint | Method | Scope | Purpose |
|----------|--------|-------|---------|
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
| `/api/tickets_api.php` | GET | read | List/triage the queue (`?status=`, `?priority=`, `?host=` [title match], `?page=`, `?limit=`) **or** read one (`?ticket_id=NNN`) with its comments |
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}``comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
### User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
@@ -250,6 +261,9 @@ Content-Type: application/json
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
| `/api/tickets_api.php` | GET | Bearer: list/triage queue or read one ticket + comments |
| `/api/ticket_comment_api.php` | POST | Bearer: add a comment (read_write scope) |
| `/api/ticket_status_api.php` | POST | Bearer: change/close status, workflow-validated (read_write scope) |
| `/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 |
+9 -2
View File
@@ -107,10 +107,17 @@ $result = $bulkOpsModel->processBulkOperation($operationId);
if (isset($result['error'])) {
$conn->close();
echo json_encode([
$response = [
'success' => false,
'error' => $result['error']
]);
];
// Let the client know it should collect a comment and retry, rather than
// showing the failure as a dead end.
if (!empty($result['requires_comment'])) {
$response['requires_comment'] = true;
http_response_code(400);
}
echo json_encode($response);
} else {
// Invalidate stats cache so dashboard tiles reflect changes immediately
require_once dirname(__DIR__) . '/models/StatsModel.php';
+10 -2
View File
@@ -59,12 +59,19 @@ try {
$keyName = trim($input['key_name'] ?? '');
$expiresInDays = $input['expires_in_days'] ?? null;
$scope = $input['scope'] ?? 'read_write';
if (empty($keyName)) {
http_response_code(400);
throw new Exception("Key name is required");
}
// Validate scope — only the two known values are allowed
if (!in_array($scope, ['read', 'read_write'], true)) {
http_response_code(400);
throw new Exception("Invalid scope: must be 'read' or 'read_write'");
}
if (strlen($keyName) > 100) {
http_response_code(400);
throw new Exception("Key name must be 100 characters or less");
@@ -86,7 +93,7 @@ try {
// Generate API key
$apiKeyModel = new ApiKeyModel($conn);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope);
if (!$result['success']) {
throw new Exception($result['error'] ?? "Failed to generate API key");
@@ -99,7 +106,7 @@ try {
'create',
'api_key',
$result['key_id'],
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
);
// Clear output buffer
@@ -112,6 +119,7 @@ try {
'api_key' => $result['api_key'],
'key_prefix' => $result['key_prefix'],
'key_id' => $result['key_id'],
'scope' => $result['scope'],
'expires_at' => $result['expires_at']
]);
} catch (Exception $e) {
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* ticket_comment_api.php Bearer-key endpoint to post a comment on a ticket.
*
* POST only. Requires 'read_write' scope.
*
* Identity = PER-KEY LABEL: the comment author (ticket_comments.user_name) is the
* API key's key_name and the linked user_id is the key's created_by.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "comment_text": "..." (required, non-empty),
* "markdown_enabled": bool (optional)
* }
* Response: {success:true, comment_id:...}
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_comment_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Posting a comment is a write — reject 'read' keys with 403 before any mutation.
$apiKeyAuth->requireScope('read_write');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
exit;
}
$context = $apiKeyAuth->getKeyContext();
$keyName = $context['key_name'] ?? 'API';
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if ($ticketId === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
exit;
}
$commentText = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentText === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'comment_text is required']);
exit;
}
$markdownEnabled = !empty($data['markdown_enabled']);
// Validate the ticket exists.
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Post the comment under the key's label / owner.
$commentModel = new CommentModel($conn);
$result = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $commentText,
'markdown_enabled' => $markdownEnabled,
], $createdBy);
if (empty($result['success'])) {
error_log('ticket_comment_api: addComment failed for ticket ' . $ticketId
. ': ' . ($result['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
$commentId = $result['comment_id'] ?? null;
// Audit trail (action 'comment' / entity 'comment' are both whitelisted).
$auditLog = new AuditLogModel($conn);
$auditLog->log($createdBy, 'comment', 'comment', (string)$commentId, [
'ticket_id' => $ticketId,
'key_name' => $keyName,
'via_api' => true,
]);
echo json_encode(['success' => true, 'comment_id' => $commentId]);
exit;
+203
View File
@@ -0,0 +1,203 @@
<?php
/**
* ticket_status_api.php Bearer-key endpoint to change a ticket's status.
*
* POST only. Requires 'read_write' scope.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "status": "..." (required target status),
* "comment": "..." (optional; REQUIRED when the transition
* requires_comment),
* "markdown_enabled": bool (optional, applies to the comment)
* }
* Response: {success:true, ticket_id, status}
*
* Mirrors api/update_ticket.php: workflow validation, requires_comment
* enforcement, updateTicket (updated_by/updated_at + closed_at handling), Matrix
* status-change notification, and StatsModel cache invalidation. When a comment
* is supplied it is posted first (per-key label) so "close with reason" is one call.
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_status_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Changing status is a write — reject 'read' keys with 403 before any mutation.
$apiKeyAuth->requireScope('read_write');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
exit;
}
$context = $apiKeyAuth->getKeyContext();
$keyName = $context['key_name'] ?? 'API';
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if ($ticketId === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
exit;
}
$newStatus = isset($data['status']) ? trim((string)$data['status']) : '';
if ($newStatus === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'status is required']);
exit;
}
$comment = isset($data['comment']) ? trim((string)$data['comment']) : '';
// Validate the ticket exists.
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$currentStatus = (string)$ticket['status'];
// Validate the transition (API key is never admin).
$workflowModel = new WorkflowModel($conn);
if (!$workflowModel->isTransitionAllowed($currentStatus, $newStatus, false)) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Status transition not allowed: ' . $currentStatus . ' -> ' . $newStatus,
]);
exit;
}
// Enforce requires_comment transitions server-side.
if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $comment === '') {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
]);
exit;
}
// Post the comment first (per-key label) so a close-with-reason is one call.
if ($comment !== '') {
$commentModel = new CommentModel($conn);
$commentResult = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $comment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $createdBy);
if (empty($commentResult['success'])) {
error_log('ticket_status_api: addComment failed for ticket ' . $ticketId
. ': ' . ($commentResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
}
// Apply the status change. updateTicket sets updated_by/updated_at and handles
// closed_at (set on close, cleared on reopen) via its own SQL.
$updateData = [
'ticket_id' => $ticketId,
'title' => $ticket['title'],
'description' => $ticket['description'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'status' => $newStatus,
'priority' => (int)$ticket['priority'],
];
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
if (empty($updateResult['success'])) {
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId
. ': ' . ($updateResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
exit;
}
// Notify, audit, and refresh stats only when the status actually changed.
if ($currentStatus !== $newStatus) {
NotificationHelper::sendStatusChangeNotification(
$ticketId,
$currentStatus,
$newStatus,
(string)$ticket['title'],
$keyName
);
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
(string)$ticket['title'],
'status_changed',
['old_status' => $currentStatus, 'new_status' => $newStatus, 'changed_by' => $keyName],
$createdBy,
$ticket['visibility'] ?? 'public'
);
// Audit trail (action 'update' / entity 'ticket' are both whitelisted).
$auditLog = new AuditLogModel($conn);
$auditLog->log($createdBy, 'update', 'ticket', $ticketId, [
'status' => ['from' => $currentStatus, 'to' => $newStatus],
'key_name' => $keyName,
'via_api' => true,
]);
// Status change is a ticket-state change — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
}
echo json_encode([
'success' => true,
'ticket_id' => $ticketId,
'status' => $newStatus,
]);
exit;
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* tickets_api.php Bearer-key read endpoint (list/triage + read-one).
*
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
* Acts as a trusted automation/server credential: reads return the full queue
* (no per-user visibility filtering).
*
* GET ?ticket_id=NNN -> {success, ticket, comments}
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
* &page=&limit=
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('tickets_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Reads only need the 'read' scope.
$apiKeyAuth->requireScope('read');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
exit;
}
$ticketModel = new TicketModel($conn);
// ── READ ONE ──────────────────────────────────────────────────────────────
if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
$ticketId = trim((string)$_GET['ticket_id']);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Flat list of comments (newest first) — same fetch the ticket view uses.
$commentModel = new CommentModel($conn);
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
echo json_encode([
'success' => true,
'ticket' => $ticket,
'comments' => $comments,
]);
exit;
}
// ── LIST / TRIAGE ───────────────────────────────────────────────────────────
$status = (isset($_GET['status']) && trim((string)$_GET['status']) !== '')
? trim((string)$_GET['status'])
: 'Open';
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if ($page < 1) {
$page = 1;
}
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25;
if ($limit < 1) {
$limit = 25;
}
if ($limit > 100) {
$limit = 100; // cap
}
$filters = [];
if (isset($_GET['priority']) && trim((string)$_GET['priority']) !== '') {
$priority = (int)$_GET['priority'];
if ($priority >= 1 && $priority <= 5) {
// Exact-priority match via the min/max range filter.
$filters['priority_min'] = $priority;
$filters['priority_max'] = $priority;
}
}
// hwmon puts the host in the title (e.g. "[hostname] ..."), so a host filter is a
// title substring match — served by getAllTickets's `search` param (title search).
$search = null;
if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
$search = trim((string)$_GET['host']);
}
// user = null => getAllTickets skips visibility filtering and returns the full
// queue (this is a trusted server credential, not an end user).
$result = $ticketModel->getAllTickets(
$page,
$limit,
$status,
'ticket_id',
'desc',
null,
null,
$search,
$filters,
null
);
echo json_encode([
'success' => true,
'tickets' => $result['tickets'],
'page' => $result['current_page'],
'total' => $result['total'],
'pages' => $result['pages'],
]);
exit;
+22
View File
@@ -66,6 +66,7 @@
--accent-green-bright: #33FFAA;
--accent-green-dim: rgba(0,255,136,0.10);
--accent-green-border: rgba(0,255,136,0.22);
--shadow-color: rgba(0,0,0,0.5);
/* --- Error / Critical --- */
--accent-red: #FF2D55;
@@ -3640,6 +3641,8 @@ html[data-theme="light"] {
--accent-red-dim: rgba(181,0,31,0.10);
--accent-amber-dim: rgba(138,90,0,0.10);
--accent-cyan-border: rgba(0,98,184,0.28);
--accent-green-border: rgba(0,109,53,0.28);
--shadow-color: rgba(50,80,130,0.18);
/* — Glows become subtle drop shadows in light mode — */
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
@@ -3737,6 +3740,25 @@ html[data-theme="light"] .lt-textarea:focus-visible {
border-color: var(--accent-cyan);
box-shadow: var(--box-glow-cyan);
}
/* Native <select> popup in light mode.
`.lt-select` sets `color-scheme: dark` on the element itself, which beats the
`color-scheme: light` declared on <html>, so the browser drew the dropdown with
dark chrome even in light mode. Reset it per element, and re-tint the option
list, which is otherwise hardcoded to #0d1117 for the dark theme. */
html[data-theme="light"] .lt-select { color-scheme: light; }
html[data-theme="light"] .lt-select option,
html[data-theme="light"] select option {
background: var(--bg-input);
color: var(--text-primary);
}
html[data-theme="light"] .lt-select option:hover,
html[data-theme="light"] .lt-select option:focus,
html[data-theme="light"] .lt-select option:checked,
html[data-theme="light"] select option:checked {
background: var(--accent-orange-dim);
color: var(--accent-orange);
}
html[data-theme="light"] .lt-label { color: var(--text-muted); }
/* — Buttons — */
+9 -4
View File
@@ -338,17 +338,22 @@ kbd {
}
/* ── Ticket preview popup ────────────────────────────────────── */
/* --lt-surface is not defined anywhere, so the background always fell through to
the hardcoded #0a0e14 a near-black panel in light mode, with no colour set at
all, so the inherited near-black body text was invisible on it. These tokens
are redefined for light mode in base.css. */
.ticket-preview-popup {
position: fixed;
z-index: 9999;
background: var(--lt-surface, #0a0e14);
border: 1px solid rgba(0, 255, 65, 0.4);
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--accent-green-border);
padding: 0.75rem;
min-width: 280px;
max-width: 360px;
font-size: 0.75rem;
pointer-events: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
box-shadow: 0 4px 20px var(--shadow-color);
}
.ticket-preview-popup .preview-header {
display: flex;
@@ -356,7 +361,7 @@ kbd {
align-items: center;
margin-bottom: 0.4rem;
}
.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; }
.ticket-preview-popup .preview-id { color: var(--accent-cyan); font-weight: 700; }
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
+12 -4
View File
@@ -54,10 +54,18 @@ body.edit-mode .editable-metadata {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.lt-status-select.lt-status-open { color: var(--lt-success, #00ff41); border-color: var(--lt-success, #00ff41); }
.lt-status-select.lt-status-pending { color: var(--lt-amber, #ffb000); border-color: var(--lt-amber, #ffb000); }
.lt-status-select.lt-status-in-progress { color: var(--lt-cyan, #00ffff); border-color: var(--lt-cyan, #00ffff); }
.lt-status-select.lt-status-closed { color: var(--lt-danger, #ff4d4d); border-color: var(--lt-danger, #ff4d4d); }
/* --lt-success / --lt-amber / --lt-cyan / --lt-danger are not defined anywhere,
so these always fell through to the hardcoded neon fallbacks unreadable on
the light theme's white input background. The --accent-* tokens carry the same
hues and are redefined for light mode in base.css.
The leading .lt-select is needed for specificity: base.css's
`html[data-theme="light"] .lt-select` (0,2,1) would otherwise outrank a plain
two-class selector and repaint every status the same near-black. */
.lt-select.lt-status-select.lt-status-open { color: var(--accent-green); border-color: var(--accent-green); }
.lt-select.lt-status-select.lt-status-pending { color: var(--accent-amber); border-color: var(--accent-amber); }
.lt-select.lt-status-select.lt-status-in-progress { color: var(--accent-cyan); border-color: var(--accent-cyan); }
.lt-select.lt-status-select.lt-status-closed { color: var(--accent-red); border-color: var(--accent-red); }
/* ── Ticket meta KV grid ─────────────────────────────────────── */
.ticket-meta-grid {
+8
View File
@@ -241,6 +241,11 @@
trigger.focus();
}
}
// Announce the close so whoever opened the modal can undo optimistic UI or
// clean up a dynamically-inserted overlay. A modal can be dismissed four
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
// the last two are handled globally here, so button-only listeners miss them.
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
}
function closeAllModals() {
@@ -2774,6 +2779,9 @@
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
resolve(value);
};
// Any dismissal counts as "no comment given", including a backdrop click or
// Escape, which close the overlay through the global handlers above.
modalEl.addEventListener('lt:modalclose', () => finish(null));
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
+115 -16
View File
@@ -157,6 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
case 'close-bulk-status-modal':
closeBulkStatusModal();
break;
case 'perform-bulk-close':
performBulkCloseAction();
break;
case 'close-bulk-close-modal':
closeBulkCloseModal();
break;
case 'perform-bulk-delete':
performBulkDelete();
break;
@@ -515,24 +521,59 @@ function bulkClose() {
return;
}
showConfirmModal(
`Close ${ticketIds.length} Ticket(s)?`,
'Are you sure you want to close these tickets?',
'warning',
() => performBulkCloseAction(ticketIds)
);
// Closing needs a reason: the default workflow marks every → Closed transition
// requires_comment, so collect it here instead of failing server-side.
const modalHtml = `
<div class="lt-modal-overlay" id="bulkCloseModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkCloseModalTitle">
<div class="lt-modal">
<div class="lt-modal-header" style="color:var(--terminal-amber)">
<span class="lt-modal-title" id="bulkCloseModalTitle">[ ! ] Close ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<div class="lt-modal-body">
<label for="bulkCloseComment">Close Reason:</label>
<textarea id="bulkCloseComment" class="lt-input lt-w-full" rows="3"
placeholder="Why are these tickets being closed?…"
style="resize:vertical;font-family:inherit;font-size:0.8rem"
aria-label="Reason for closing the tickets"></textarea>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
Posted as a comment on every ticket closed. Tickets whose workflow
forbids closing from their current status are skipped.
</p>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-close" class="lt-btn lt-btn-primary">CLOSE TICKETS</button>
<button data-action="close-bulk-close-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
openModalWithDismiss('bulkCloseModal', closeBulkCloseModal);
}
function closeBulkCloseModal() {
lt.modal.close('bulkCloseModal');
const modal = document.getElementById('bulkCloseModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performBulkCloseAction(ticketIds) {
ticketIds = ticketIds || getSelectedTicketIds();
const commentEl = document.getElementById('bulkCloseComment');
const comment = commentEl ? commentEl.value.trim() : '';
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_close',
ticket_ids: ticketIds
ticket_ids: ticketIds,
parameters: { comment: comment }
})
.then(data => {
closeBulkCloseModal();
if (data.success) {
if (data.failed > 0) {
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(bulkResultMessage('Bulk close', data), 6000);
} else {
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
}
@@ -542,6 +583,14 @@ function performBulkCloseAction(ticketIds) {
}
})
.catch(error => {
// Missing required comment — keep the modal open so it can be entered.
if (error && error.data && error.data.requires_comment) {
lt.toast.warning(error.data.error || 'A close reason is required', 6000);
const ta = document.getElementById('bulkCloseComment');
if (ta) ta.focus();
return;
}
closeBulkCloseModal();
lt.toast.error('Bulk close failed: ' + error.message, 5000);
});
}
@@ -584,7 +633,7 @@ function showBulkAssignModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkAssignModal');
openModalWithDismiss('bulkAssignModal', closeBulkAssignModal);
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
lt.api.get('/api/get_users.php')
@@ -682,7 +731,7 @@ function showBulkPriorityModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkPriorityModal');
openModalWithDismiss('bulkPriorityModal', closeBulkPriorityModal);
}
function closeBulkPriorityModal() {
@@ -777,6 +826,15 @@ function showBulkStatusModal() {
<option value="">Select Status...</option>
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
</select>
<label for="bulkStatusComment" style="margin-top:0.75rem">Reason / Comment:</label>
<textarea id="bulkStatusComment" class="lt-input lt-w-full" rows="3"
placeholder="Reason for the status change…"
style="resize:vertical;font-family:inherit;font-size:0.8rem"
aria-label="Reason for the bulk status change"></textarea>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
Required for transitions the Workflow Designer marks as needing a comment
(e.g. closing a ticket). Posted as a comment on every ticket changed.
</p>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
@@ -787,7 +845,7 @@ function showBulkStatusModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkStatusModal');
openModalWithDismiss('bulkStatusModal', closeBulkStatusModal);
}
function closeBulkStatusModal() {
@@ -807,16 +865,19 @@ function performBulkStatusChange() {
return;
}
const commentEl = document.getElementById('bulkStatusComment');
const comment = commentEl ? commentEl.value.trim() : '';
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_status',
ticket_ids: ticketIds,
parameters: { status: status }
parameters: { status: status, comment: comment }
})
.then(data => {
closeBulkStatusModal();
if (data.success) {
if (data.failed > 0) {
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(bulkResultMessage('Status update', data), 6000);
} else {
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
}
@@ -826,10 +887,32 @@ function performBulkStatusChange() {
}
})
.catch(error => {
// Workflow needs a comment for at least one selected ticket — keep the
// modal open so the reason can be typed in without re-selecting.
if (error && error.data && error.data.requires_comment) {
lt.toast.warning(error.data.error || 'A comment is required for this status change', 6000);
const ta = document.getElementById('bulkStatusComment');
if (ta) ta.focus();
return;
}
closeBulkStatusModal();
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
});
}
/**
* Build a result message for a partially-successful bulk operation, surfacing the
* per-ticket reasons (e.g. "transition not allowed") instead of a bare count.
*/
function bulkResultMessage(label, data) {
let msg = `${label}: ${data.processed} succeeded, ${data.failed} failed`;
if (Array.isArray(data.errors) && data.errors.length) {
msg += ' — ' + data.errors.slice(0, 3).join('; ');
if (data.errors.length > 3) msg += ` (+${data.errors.length - 3} more)`;
}
return msg;
}
// Bulk Delete
function showBulkDeleteModal() {
const ticketIds = getSelectedTicketIds();
@@ -859,7 +942,7 @@ function showBulkDeleteModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkDeleteModal');
openModalWithDismiss('bulkDeleteModal', closeBulkDeleteModal);
}
function closeBulkDeleteModal() {
@@ -949,6 +1032,22 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
// Backdrop click / Escape close the overlay via base.js's global handlers.
modal.addEventListener('lt:modalclose', () => cleanup(onCancel));
}
/**
* Open a dynamically-inserted modal and make sure it tears itself down however it
* is dismissed. base.js handles backdrop clicks and Escape globally, so wiring
* only the /Cancel buttons leaves the overlay in the DOM and the next open
* inserts a second element with the same id, which then shadows the live one.
*/
function openModalWithDismiss(modalId, onDismiss) {
lt.modal.open(modalId);
const el = document.getElementById(modalId);
// lt.modal.close() early-returns once .is-open is gone, so the close call
// inside onDismiss cannot re-enter this listener.
if (el) el.addEventListener('lt:modalclose', onDismiss);
}
// ========================================
@@ -986,7 +1085,7 @@ function quickStatusChange(ticketId, currentStatus) {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickStatusModal');
openModalWithDismiss('quickStatusModal', closeQuickStatusModal);
}
function closeQuickStatusModal() {
@@ -1053,7 +1152,7 @@ function quickAssign(ticketId) {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickAssignModal');
openModalWithDismiss('quickAssignModal', closeQuickAssignModal);
lt.api.get('/api/get_users.php')
.then(data => {
+22 -2
View File
@@ -284,7 +284,15 @@ function addComment() {
// Clear the comment box
const nc = document.getElementById('newComment');
if (nc) nc.value = '';
// Clear the live preview — clearing the textarea programmatically
// does not fire 'input', so updatePreview() never runs
const previewDiv = document.getElementById('markdownPreview');
if (previewDiv) {
previewDiv.innerHTML = '';
previewDiv.classList.add('is-hidden');
}
// Format the comment text for display
let displayText;
if (isMarkdownEnabled) {
@@ -521,7 +529,19 @@ function updateTicketStatus() {
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
let settled = false;
const cleanup = (ok) => {
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
settled = true;
lt.modal.close(modalId);
setTimeout(() => modal.remove(), 300);
if (!ok) statusSelect.selectedIndex = 0;
};
// Backdrop click and Escape close the overlay through base.js's global
// handlers. Without this the dropdown kept displaying the new status
// while the server was never called, so the ticket looked closed until
// a reload revealed it was still open.
modal.addEventListener('lt:modalclose', () => cleanup(false));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
+3
View File
@@ -72,6 +72,9 @@ try {
exit;
}
// Ticket creation is a write — a read-only key must be rejected with 403.
$apiKeyAuth->requireScope('read_write');
$userId = $systemUser['user_id'];
// Parse input regardless of content-type header
+9 -1
View File
@@ -331,7 +331,15 @@ switch (true) {
requireAdmin($currentUser);
require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn);
$apiKeys = $apiKeyModel->getAllKeys();
// Validate the requested page to a positive int (default 1)
$apiKeysPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if ($apiKeysPage < 1) {
$apiKeysPage = 1;
}
$apiKeysPerPage = 20;
$apiKeys = $apiKeyModel->getAllKeys($apiKeysPage, $apiKeysPerPage);
include 'views/admin/ApiKeysView.php';
break;
+81
View File
@@ -13,6 +13,14 @@ class ApiKeyAuth
private $userModel;
private $conn;
/**
* Context of the API key validated by the most recent authenticate()/
* verifyOptional() call, or null if none succeeded.
*
* @var array|null
*/
private $keyContext = null;
public function __construct($conn)
{
$this->conn = $conn;
@@ -20,6 +28,57 @@ class ApiKeyAuth
$this->userModel = new UserModel($conn);
}
/**
* Store the validated key's context for later scope/attribution checks.
*
* @param array $keyData Row returned by ApiKeyModel::validateKey()
*/
private function setKeyContext(array $keyData)
{
$this->keyContext = [
'scope' => $keyData['scope'] ?? 'read_write',
'key_name' => $keyData['key_name'] ?? null,
'created_by' => $keyData['created_by'] ?? null,
'api_key_id' => $keyData['api_key_id'] ?? null,
];
}
/**
* Get the context of the authenticated API key.
*
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null
*/
public function getKeyContext(): ?array
{
return $this->keyContext;
}
/**
* Enforce that the authenticated key satisfies the required scope.
*
* A 'read' key satisfies only 'read'; a 'read_write' key satisfies both
* 'read' and 'read_write'. On failure a 403 JSON error is sent and the
* script exits.
*
* @param string $needed Required scope ('read' or 'read_write')
*/
public function requireScope(string $needed): void
{
$current = $this->keyContext['scope'] ?? null;
// 'read_write' can do anything; 'read' can only satisfy a 'read' need.
$ok = ($current === 'read_write')
|| ($current === 'read' && $needed === 'read');
if (!$ok) {
$this->sendForbidden(
'API key scope "' . ($current ?? 'none') . '" is insufficient; "'
. $needed . '" is required'
);
exit;
}
}
/**
* Authenticate using API key from Authorization header
*
@@ -52,6 +111,9 @@ class ApiKeyAuth
exit;
}
// Record key context (scope / attribution) for callers to inspect.
$this->setKeyContext($keyData);
// Get system user (or the user who created the key)
$user = $this->userModel->getSystemUser();
@@ -113,6 +175,22 @@ class ApiKeyAuth
]);
}
/**
* Send 403 Forbidden response (e.g. insufficient scope)
*
* @param string $message Error message
*/
private function sendForbidden($message)
{
header('HTTP/1.1 403 Forbidden');
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Forbidden',
'message' => $message
]);
}
/**
* Verify API key without throwing errors (for optional auth)
*
@@ -137,6 +215,9 @@ class ApiKeyAuth
return null;
}
// Record key context (scope / attribution) for callers to inspect.
$this->setKeyContext($keyData);
$user = $this->userModel->getSystemUser();
if ($user) {
+3 -1
View File
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `api_keys` (
`key_hash` varchar(255) NOT NULL,
`key_prefix` varchar(20) NOT NULL,
`is_active` tinyint(1) DEFAULT 1,
`scope` enum('read','read_write') NOT NULL DEFAULT 'read_write',
`created_by` int(11) DEFAULT NULL,
`last_used` timestamp NULL DEFAULT NULL,
`expires_at` timestamp NULL DEFAULT NULL,
@@ -58,7 +59,8 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
`ticket_ids` text NOT NULL,
`performed_by` int(11) NOT NULL,
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
`status` varchar(20) DEFAULT 'pending',
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
`status` varchar(32) DEFAULT 'pending',
`total_tickets` int(11) DEFAULT NULL,
`processed_tickets` int(11) DEFAULT 0,
`failed_tickets` int(11) DEFAULT 0,
@@ -0,0 +1,12 @@
-- Widen bulk_operations.status
--
-- The code writes 'completed_with_errors' (21 chars) when a bulk operation
-- finishes with per-ticket failures, but the column was varchar(20), so the
-- write failed with "Data too long for column 'status'". This was unreachable
-- while bulk status changes forced every transition through; now that they
-- honour the Workflow Designer, partial failures are a normal outcome.
--
-- Safe to re-run.
ALTER TABLE `bulk_operations`
MODIFY COLUMN `status` varchar(32) DEFAULT 'pending';
+56 -9
View File
@@ -18,10 +18,19 @@ class ApiKeyModel
* @param string $keyName Descriptive name for the key
* @param int $createdBy User ID who created the key
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
*/
public function createKey($keyName, $createdBy, $expiresInDays = null)
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write')
{
// Validate the requested scope — only the two known values are allowed
if (!in_array($scope, ['read', 'read_write'], true)) {
return [
'success' => false,
'error' => "Invalid scope: must be 'read' or 'read_write'"
];
}
// Generate random API key (32 bytes = 64 hex characters)
$apiKey = bin2hex(random_bytes(32));
@@ -39,9 +48,10 @@ class ApiKeyModel
// Insert API key into database
$stmt = $this->conn->prepare(
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) "
. "VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt);
if ($stmt->execute()) {
$keyId = $this->conn->insert_id;
@@ -52,6 +62,7 @@ class ApiKeyModel
'api_key' => $apiKey, // Return plaintext key ONCE
'key_prefix' => $keyPrefix,
'key_id' => $keyId,
'scope' => $scope,
'expires_at' => $expiresAt
];
} else {
@@ -96,6 +107,13 @@ class ApiKeyModel
$keyData = $result->fetch_assoc();
$stmt->close();
// Ensure a scope is always present. On an un-migrated database the column
// does not exist yet (or is null), in which case we treat the key as
// full-access so existing integrations keep working.
if (!isset($keyData['scope']) || $keyData['scope'] === null || $keyData['scope'] === '') {
$keyData['scope'] = 'read_write';
}
// Check expiration
if ($keyData['expires_at'] !== null) {
$expiresAt = strtotime($keyData['expires_at']);
@@ -156,18 +174,41 @@ class ApiKeyModel
}
/**
* Get all API keys (for admin panel)
* Get a page of API keys (for admin panel)
*
* @return array Array of API key records (without hashes)
* Active keys are listed first, then newest first within each group.
*
* @param int $page 1-based page number
* @param int $perPage Rows per page
* @return array ['keys' => array, 'total' => int, 'page' => int, 'perPage' => int]
*/
public function getAllKeys()
public function getAllKeys($page = 1, $perPage = 20)
{
// Normalise pagination inputs
$page = max(1, (int)$page);
$perPage = (int)$perPage;
if ($perPage < 1) {
$perPage = 20;
}
$offset = ($page - 1) * $perPage;
// Total count for pagination controls
$total = 0;
$countResult = $this->conn->query("SELECT COUNT(*) AS total FROM api_keys");
if ($countResult) {
$countRow = $countResult->fetch_assoc();
$total = (int)($countRow['total'] ?? 0);
$countResult->free();
}
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
LEFT JOIN users u ON ak.created_by = u.user_id
ORDER BY ak.created_at DESC"
ORDER BY ak.is_active DESC, ak.created_at DESC
LIMIT ? OFFSET ?"
);
$stmt->bind_param("ii", $perPage, $offset);
$stmt->execute();
$result = $stmt->get_result();
@@ -179,7 +220,13 @@ class ApiKeyModel
}
$stmt->close();
return $keys;
return [
'keys' => $keys,
'total' => $total,
'page' => $page,
'perPage' => $perPage
];
}
/**
+139 -13
View File
@@ -7,11 +7,47 @@ class BulkOperationsModel
{
private $conn;
/** @var WorkflowModel|null Lazily created; only needed by status-changing operations */
private $workflowModel = null;
/** @var CommentModel|null Lazily created; only needed when a status change carries a comment */
private $commentModel = null;
/** @var array<int,string> user_id → display name, resolved once per request */
private $userNames = [];
public function __construct($conn)
{
$this->conn = $conn;
}
/**
* Workflow model, created on first use.
*/
private function workflow(): WorkflowModel
{
if ($this->workflowModel === null) {
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
$this->workflowModel = new WorkflowModel($this->conn);
}
return $this->workflowModel;
}
/**
* The status a bulk operation is trying to move tickets into, or null for
* operations that don't change status.
*/
private function targetStatusFor(string $operationType, array $parameters): ?string
{
if ($operationType === 'bulk_close') {
return 'Closed';
}
if ($operationType === 'bulk_status') {
return isset($parameters['status']) ? (string)$parameters['status'] : null;
}
return null;
}
/**
* Create a new bulk operation record
*
@@ -100,6 +136,30 @@ class BulkOperationsModel
// Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
// Status-changing operations honour the Workflow Designer. If any ticket in
// the selection needs a comment for its transition, reject the whole batch
// before mutating anything so the client can collect one — a partially
// applied batch is worse than none.
$targetStatus = $this->targetStatusFor($operation['operation_type'], is_array($parameters) ? $parameters : []);
$bulkComment = trim((string)($parameters['comment'] ?? ''));
if ($targetStatus !== null && $bulkComment === '') {
foreach ($ticketIds as $tid) {
$t = $ticketsById[trim($tid)] ?? null;
if (!$t || $t['status'] === $targetStatus) {
continue;
}
if ($this->workflow()->transitionRequiresComment($t['status'], $targetStatus)) {
return [
'processed' => 0,
'failed' => count($ticketIds),
'error' => 'A comment is required to change status from '
. $t['status'] . ' → ' . $targetStatus,
'requires_comment' => true,
];
}
}
}
// Start transaction for data consistency
$this->conn->begin_transaction();
@@ -113,11 +173,32 @@ class BulkOperationsModel
$success = false;
try {
// NOTE: bulk_status / bulk_close intentionally do NOT run
// WorkflowModel::isTransitionAllowed(). Bulk operations are an
// admin-only escape hatch for forcing ticket states (e.g. mass
// re-opening), so they bypass the workflow transition rules that
// the single-ticket update path enforces. This is by design.
// bulk_status / bulk_close enforce the same Workflow Designer
// rules as the single-ticket path: a transition the designer
// doesn't define is refused, and requires_comment is honoured
// (checked up front, above). requires_admin is satisfied because
// api/bulk_operation.php already gates the endpoint on admin.
if ($targetStatus !== null) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
// Already in the requested state — nothing to do, and
// reporting a no-op as a failure would just confuse.
$processed++;
continue;
}
$allowed = $currentTicket === null || $this->workflow()->isTransitionAllowed(
$currentTicket['status'],
$targetStatus,
true
);
if (!$allowed) {
$failed++;
$errors[] = "Ticket $ticketId: transition not allowed ("
. $currentTicket['status'] . ' → ' . $targetStatus . ')';
continue;
}
}
switch ($operation['operation_type']) {
case 'bulk_close':
// Get current ticket from pre-loaded batch
@@ -232,6 +313,12 @@ class BulkOperationsModel
}
if ($success) {
// Persist the status-change reason as a real comment, so a
// bulk close is as auditable on the ticket as a single close
// (where the client posts the comment before updating).
if ($targetStatus !== null && $bulkComment !== '') {
$this->postBulkComment($ticketId, (int)$operation['performed_by'], $bulkComment);
}
$processed++;
} else {
$failed++;
@@ -290,14 +377,22 @@ class BulkOperationsModel
];
}
// Update operation status
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
$stmt->execute();
$stmt->close();
// Update operation status. This is bookkeeping only and runs after the
// ticket changes are committed, so a failure here (e.g. the status column
// not yet widened by 001_widen_bulk_operations_status.sql on an instance
// deployed ahead of its migrations) must not turn a completed operation
// into an error response.
try {
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
$stmt->execute();
$stmt->close();
} catch (Throwable $e) {
error_log("Bulk operation $operationId completed but status bookkeeping failed: " . $e->getMessage());
}
$result = ['processed' => $processed, 'failed' => $failed];
if (!empty($errors)) {
@@ -350,6 +445,37 @@ class BulkOperationsModel
return null;
}
/**
* Post the bulk status-change reason as a comment on one ticket.
*
* Runs inside the caller's transaction, so a rollback drops the comment along
* with the status change.
*/
private function postBulkComment(string $ticketId, int $userId, string $text): void
{
require_once dirname(__DIR__) . '/models/CommentModel.php';
if ($this->commentModel === null) {
$this->commentModel = new CommentModel($this->conn);
}
if (!isset($this->userNames[$userId])) {
$stmt = $this->conn->prepare(
"SELECT COALESCE(NULLIF(display_name, ''), username) AS name FROM users WHERE user_id = ? LIMIT 1"
);
$stmt->bind_param("i", $userId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
$this->userNames[$userId] = $row['name'] ?? 'User';
}
$this->commentModel->addComment($ticketId, [
'user_name' => $this->userNames[$userId],
'comment_text' => $text,
'markdown_enabled' => 0,
], $userId);
}
/**
* Check whether a user ID exists.
*/
+94 -12
View File
@@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php';
<option value="365">1 year</option>
</select>
</div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" for="keyScope">Scope</label>
<select id="keyScope" class="lt-select">
<option value="read_write" selected>read_write</option>
<option value="read">read</option>
</select>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
</p>
<!-- New key display (hidden by default) -->
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
@@ -63,6 +73,7 @@ include __DIR__ . '/../../views/layout_header.php';
<tr>
<th scope="col">Name</th>
<th scope="col">Key Prefix</th>
<th scope="col">Scope</th>
<th scope="col">Created By</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
@@ -72,14 +83,26 @@ include __DIR__ . '/../../views/layout_header.php';
</tr>
</thead>
<tbody>
<?php if (empty($apiKeys)) : ?>
<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(); ?>
<?php
$apiKeysList = $apiKeys['keys'] ?? [];
if (empty($apiKeysList)) : ?>
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php else :
foreach ($apiKeysList as $key) : ?>
<?php
$expired = $key['expires_at'] && strtotime($key['expires_at']) < time();
$scope = $key['scope'] ?? 'read_write';
?>
<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="Scope">
<?php if ($scope === 'read') : ?>
<span class="lt-status lt-status-closed"><?= htmlspecialchars($scope) ?></span>
<?php else : ?>
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
<?php endif ?>
</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' ?>">
@@ -104,11 +127,30 @@ include __DIR__ . '/../../views/layout_header.php';
<?php endif ?>
</td>
</tr>
<?php endforeach;
endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php
$akPage = (int)($apiKeys['page'] ?? 1);
$akPerPage = max(1, (int)($apiKeys['perPage'] ?? 20));
$akTotal = (int)($apiKeys['total'] ?? 0);
$akPages = (int)ceil($akTotal / $akPerPage);
?>
<?php if ($akPages > 1) : ?>
<div class="lt-pagination" role="navigation" aria-label="API keys pagination">
<?php if ($akPage > 1) : ?>
<a href="/admin/api-keys?page=<?= $akPage - 1 ?>" class="lt-btn lt-btn-sm" aria-label="Previous page">&#xAB; Prev</a>
<?php endif ?>
<span class="lt-text-xs lt-text-muted">Page <?= $akPage ?> of <?= $akPages ?></span>
<?php if ($akPage < $akPages) : ?>
<a href="/admin/api-keys?page=<?= $akPage + 1 ?>" class="lt-btn lt-btn-sm" aria-label="Next page">Next &#xBB;</a>
<?php endif ?>
</div>
<?php endif ?>
</div>
</div>
@@ -127,17 +169,56 @@ include __DIR__ . '/../../views/layout_header.php';
</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>
<?php $apiBase = 'https://' . htmlspecialchars($GLOBALS['config']['APP_DOMAIN'] ?? 'your-instance', ENT_QUOTES); ?>
<p class="lt-text-sm lt-text-muted" style="margin-top:0.75rem">
<strong>Scopes:</strong> a <code>read</code> key may only use the <code>GET</code> endpoints;
a <code>read_write</code> key may also create tickets, post comments, and change status.
All endpoints are Bearer-authenticated and rate-limited. Comments and status changes made via
the API are attributed to the key's name.
</p>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Create a ticket</strong> (read_write):</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/create_ticket_api.php \
<pre><code>curl -X POST <?= $apiBase ?>/create_ticket_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
</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>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>List / triage the queue</strong> (read). Filters: <code>status</code>, <code>priority</code> (1-5), <code>host</code> (title match), <code>page</code>, <code>limit</code>:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?status=Open&priority=2&limit=25" \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Read one ticket + its comments</strong> (read):</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?ticket_id=123456789" \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Post a comment</strong> (read_write). <code>markdown_enabled</code> is optional:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_comment_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"123456789","comment_text":"Investigating.","markdown_enabled":true}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Change / close status</strong> (read_write, workflow-validated). <code>comment</code> is required for transitions that require one (e.g. closing) and is posted as the reason:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_status_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"123456789","status":"Closed","comment":"Resolved: disk replaced."}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem">Keep keys secure and rotate them regularly. Scope automation keys to <code>read</code> unless they need to write.</p>
</div>
</div>
@@ -160,8 +241,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
e.preventDefault();
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
var keyScope = document.getElementById('keyScope').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 })
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope })
.then(function (data) {
if (data.success) {
document.getElementById('newKeyValue').value = data.api_key;