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
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
This commit was merged in pull request #26.
This commit is contained in:
@@ -94,11 +94,22 @@ The following features are intentionally **not planned** for this system:
|
|||||||
- **Required Fields**: Mark fields as required for validation
|
- **Required Fields**: Mark fields as required for validation
|
||||||
|
|
||||||
### API Key Management
|
### 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
|
- **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
|
- **Expiration**: Optional expiration dates for keys
|
||||||
- **Revocation**: Revoke compromised keys instantly
|
- **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
|
### User Management & Authentication
|
||||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||||
- **Role-Based Access**: Admin and standard user roles
|
- **Role-Based Access**: Admin and standard user roles
|
||||||
@@ -250,6 +261,9 @@ Content-Type: application/json
|
|||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
|
| `/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/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||||
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||||
| `/api/add_comment.php` | POST | Add comment to ticket |
|
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||||
|
|||||||
@@ -59,12 +59,19 @@ try {
|
|||||||
|
|
||||||
$keyName = trim($input['key_name'] ?? '');
|
$keyName = trim($input['key_name'] ?? '');
|
||||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||||
|
$scope = $input['scope'] ?? 'read_write';
|
||||||
|
|
||||||
if (empty($keyName)) {
|
if (empty($keyName)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
throw new Exception("Key name is required");
|
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) {
|
if (strlen($keyName) > 100) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
throw new Exception("Key name must be 100 characters or less");
|
throw new Exception("Key name must be 100 characters or less");
|
||||||
@@ -86,7 +93,7 @@ try {
|
|||||||
|
|
||||||
// Generate API key
|
// Generate API key
|
||||||
$apiKeyModel = new ApiKeyModel($conn);
|
$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']) {
|
if (!$result['success']) {
|
||||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||||
@@ -99,7 +106,7 @@ try {
|
|||||||
'create',
|
'create',
|
||||||
'api_key',
|
'api_key',
|
||||||
$result['key_id'],
|
$result['key_id'],
|
||||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
|
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear output buffer
|
// Clear output buffer
|
||||||
@@ -112,6 +119,7 @@ try {
|
|||||||
'api_key' => $result['api_key'],
|
'api_key' => $result['api_key'],
|
||||||
'key_prefix' => $result['key_prefix'],
|
'key_prefix' => $result['key_prefix'],
|
||||||
'key_id' => $result['key_id'],
|
'key_id' => $result['key_id'],
|
||||||
|
'scope' => $result['scope'],
|
||||||
'expires_at' => $result['expires_at']
|
'expires_at' => $result['expires_at']
|
||||||
]);
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -72,6 +72,9 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket creation is a write — a read-only key must be rejected with 403.
|
||||||
|
$apiKeyAuth->requireScope('read_write');
|
||||||
|
|
||||||
$userId = $systemUser['user_id'];
|
$userId = $systemUser['user_id'];
|
||||||
|
|
||||||
// Parse input regardless of content-type header
|
// Parse input regardless of content-type header
|
||||||
|
|||||||
@@ -331,7 +331,15 @@ switch (true) {
|
|||||||
requireAdmin($currentUser);
|
requireAdmin($currentUser);
|
||||||
require_once 'models/ApiKeyModel.php';
|
require_once 'models/ApiKeyModel.php';
|
||||||
$apiKeyModel = new ApiKeyModel($conn);
|
$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';
|
include 'views/admin/ApiKeysView.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ class ApiKeyAuth
|
|||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
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)
|
public function __construct($conn)
|
||||||
{
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
@@ -20,6 +28,57 @@ class ApiKeyAuth
|
|||||||
$this->userModel = new UserModel($conn);
|
$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
|
* Authenticate using API key from Authorization header
|
||||||
*
|
*
|
||||||
@@ -52,6 +111,9 @@ class ApiKeyAuth
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record key context (scope / attribution) for callers to inspect.
|
||||||
|
$this->setKeyContext($keyData);
|
||||||
|
|
||||||
// Get system user (or the user who created the key)
|
// Get system user (or the user who created the key)
|
||||||
$user = $this->userModel->getSystemUser();
|
$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)
|
* Verify API key without throwing errors (for optional auth)
|
||||||
*
|
*
|
||||||
@@ -137,6 +215,9 @@ class ApiKeyAuth
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record key context (scope / attribution) for callers to inspect.
|
||||||
|
$this->setKeyContext($keyData);
|
||||||
|
|
||||||
$user = $this->userModel->getSystemUser();
|
$user = $this->userModel->getSystemUser();
|
||||||
|
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `api_keys` (
|
|||||||
`key_hash` varchar(255) NOT NULL,
|
`key_hash` varchar(255) NOT NULL,
|
||||||
`key_prefix` varchar(20) NOT NULL,
|
`key_prefix` varchar(20) NOT NULL,
|
||||||
`is_active` tinyint(1) DEFAULT 1,
|
`is_active` tinyint(1) DEFAULT 1,
|
||||||
|
`scope` enum('read','read_write') NOT NULL DEFAULT 'read_write',
|
||||||
`created_by` int(11) DEFAULT NULL,
|
`created_by` int(11) DEFAULT NULL,
|
||||||
`last_used` timestamp NULL DEFAULT NULL,
|
`last_used` timestamp NULL DEFAULT NULL,
|
||||||
`expires_at` timestamp NULL DEFAULT NULL,
|
`expires_at` timestamp NULL DEFAULT NULL,
|
||||||
|
|||||||
+56
-9
@@ -18,10 +18,19 @@ class ApiKeyModel
|
|||||||
* @param string $keyName Descriptive name for the key
|
* @param string $keyName Descriptive name for the key
|
||||||
* @param int $createdBy User ID who created the key
|
* @param int $createdBy User ID who created the key
|
||||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
* @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)
|
// Generate random API key (32 bytes = 64 hex characters)
|
||||||
$apiKey = bin2hex(random_bytes(32));
|
$apiKey = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
@@ -39,9 +48,10 @@ class ApiKeyModel
|
|||||||
|
|
||||||
// Insert API key into database
|
// Insert API key into database
|
||||||
$stmt = $this->conn->prepare(
|
$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()) {
|
if ($stmt->execute()) {
|
||||||
$keyId = $this->conn->insert_id;
|
$keyId = $this->conn->insert_id;
|
||||||
@@ -52,6 +62,7 @@ class ApiKeyModel
|
|||||||
'api_key' => $apiKey, // Return plaintext key ONCE
|
'api_key' => $apiKey, // Return plaintext key ONCE
|
||||||
'key_prefix' => $keyPrefix,
|
'key_prefix' => $keyPrefix,
|
||||||
'key_id' => $keyId,
|
'key_id' => $keyId,
|
||||||
|
'scope' => $scope,
|
||||||
'expires_at' => $expiresAt
|
'expires_at' => $expiresAt
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
@@ -96,6 +107,13 @@ class ApiKeyModel
|
|||||||
$keyData = $result->fetch_assoc();
|
$keyData = $result->fetch_assoc();
|
||||||
$stmt->close();
|
$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
|
// Check expiration
|
||||||
if ($keyData['expires_at'] !== null) {
|
if ($keyData['expires_at'] !== null) {
|
||||||
$expiresAt = strtotime($keyData['expires_at']);
|
$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(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
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();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
@@ -179,7 +220,13 @@ class ApiKeyModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $keys;
|
|
||||||
|
return [
|
||||||
|
'keys' => $keys,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'perPage' => $perPage
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+94
-12
@@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<option value="365">1 year</option>
|
<option value="365">1 year</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
|
||||||
</form>
|
</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) -->
|
<!-- New key display (hidden by default) -->
|
||||||
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
|
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
|
||||||
@@ -63,6 +73,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Name</th>
|
<th scope="col">Name</th>
|
||||||
<th scope="col">Key Prefix</th>
|
<th scope="col">Key Prefix</th>
|
||||||
|
<th scope="col">Scope</th>
|
||||||
<th scope="col">Created By</th>
|
<th scope="col">Created By</th>
|
||||||
<th scope="col">Created</th>
|
<th scope="col">Created</th>
|
||||||
<th scope="col">Expires</th>
|
<th scope="col">Expires</th>
|
||||||
@@ -72,14 +83,26 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($apiKeys)) : ?>
|
<?php
|
||||||
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
$apiKeysList = $apiKeys['keys'] ?? [];
|
||||||
<?php else :
|
if (empty($apiKeysList)) : ?>
|
||||||
foreach ($apiKeys as $key) : ?>
|
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||||
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
<?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'] ?>">
|
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
||||||
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||||
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</code></td>
|
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</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 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="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' ?>">
|
<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 ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach;
|
<?php endforeach;
|
||||||
endif ?>
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">« 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 »</a>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,17 +169,56 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
|
<?php $apiBase = 'https://' . htmlspecialchars($GLOBALS['config']['APP_DOMAIN'] ?? 'your-instance', ENT_QUOTES); ?>
|
||||||
Example — create a ticket via cURL:<br>
|
<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>
|
||||||
|
|
||||||
|
<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-block">
|
||||||
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
<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 "Authorization: Bearer YOUR_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
|
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,8 +241,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var keyName = document.getElementById('keyName').value.trim();
|
var keyName = document.getElementById('keyName').value.trim();
|
||||||
var expiresIn = document.getElementById('expiresIn').value;
|
var expiresIn = document.getElementById('expiresIn').value;
|
||||||
|
var keyScope = document.getElementById('keyScope').value;
|
||||||
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
|
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) {
|
.then(function (data) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById('newKeyValue').value = data.api_key;
|
document.getElementById('newKeyValue').value = data.api_key;
|
||||||
|
|||||||
Reference in New Issue
Block a user