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>
284 lines
14 KiB
PHP
284 lines
14 KiB
PHP
<?php
|
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
|
$pageTitle = 'API Keys';
|
|
$activeNav = 'admin-api-keys';
|
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
|
include __DIR__ . '/../../views/layout_header.php';
|
|
?>
|
|
|
|
<div class="lt-page-header">
|
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
|
<span class="lt-text-muted lt-text-xs">/</span>
|
|
<span class="lt-text-muted lt-text-xs">Admin: API Keys</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generate new key -->
|
|
<div class="lt-frame lt-mb-md">
|
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
|
<div class="lt-section-header">Generate New API Key</div>
|
|
<div class="lt-section-body">
|
|
<form id="generateKeyForm" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-flex-align-end">
|
|
<div class="lt-form-group" style="flex:2;margin:0">
|
|
<label class="lt-label" for="keyName">Key Name *</label>
|
|
<input type="text" id="keyName" required class="lt-input" placeholder="e.g., CI/CD Pipeline">
|
|
</div>
|
|
<div class="lt-form-group" style="flex:1;margin:0">
|
|
<label class="lt-label" for="expiresIn">Expires In</label>
|
|
<select id="expiresIn" class="lt-select">
|
|
<option value="">Never</option>
|
|
<option value="30">30 days</option>
|
|
<option value="90">90 days</option>
|
|
<option value="180">180 days</option>
|
|
<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">
|
|
<div class="lt-subsection-header lt-text-amber">⚠ Copy this key now — you won't see it again!</div>
|
|
<div class="lt-flex lt-flex-gap-sm lt-mt-sm">
|
|
<input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace;opacity:1;cursor:text">
|
|
<button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Existing keys -->
|
|
<div class="lt-frame lt-mb-md">
|
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
|
<div class="lt-section-header">Existing API Keys</div>
|
|
<div class="lt-section-body">
|
|
<div class="lt-table-wrap">
|
|
<table class="lt-table lt-table-responsive" aria-label="API keys">
|
|
<thead>
|
|
<tr>
|
|
<th 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>
|
|
<th scope="col">Last Used</th>
|
|
<th scope="col">Status</th>
|
|
<th scope="col">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?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']) ?>…</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' ?>">
|
|
<?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
|
|
</td>
|
|
<td data-label="Last Used" class="lt-text-xs lt-text-muted">
|
|
<?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
|
|
</td>
|
|
<td data-label="Status">
|
|
<?php if ($key['is_active']) : ?>
|
|
<span class="lt-status lt-status-open">Active</span>
|
|
<?php else : ?>
|
|
<span class="lt-status lt-status-closed">Revoked</span>
|
|
<?php endif ?>
|
|
</td>
|
|
<td data-label="Actions">
|
|
<?php if ($key['is_active']) : ?>
|
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
|
data-action="revoke-key" data-id="<?= (int)$key['api_key_id'] ?>">REVOKE</button>
|
|
<?php else : ?>
|
|
<span class="lt-text-muted lt-text-xs">—</span>
|
|
<?php endif ?>
|
|
</td>
|
|
</tr>
|
|
<?php 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">« 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>
|
|
|
|
<!-- API usage -->
|
|
<div class="lt-frame">
|
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
|
<div class="lt-section-header">API Usage</div>
|
|
<div class="lt-section-body">
|
|
<p class="lt-text-sm lt-text-muted">Include the API key in your requests using the Authorization header:</p>
|
|
<div class="lt-code-block">
|
|
<div class="lt-code-header">
|
|
<span class="lt-code-lang">HTTP HEADER</span>
|
|
<button type="button" class="lt-code-copy lt-btn-sm"
|
|
data-copy="Authorization: Bearer YOUR_API_KEY"
|
|
data-copy-toast>COPY</button>
|
|
</div>
|
|
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
|
</div>
|
|
<?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 <?= $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.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>
|
|
|
|
<script nonce="<?= $nonce ?>">
|
|
document.addEventListener('click', function (e) {
|
|
var target = e.target.closest('[data-action]');
|
|
if (!target) return;
|
|
switch (target.getAttribute('data-action')) {
|
|
case 'copy-api-key': copyApiKey(); break;
|
|
case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
|
|
case 'copy-header-example':
|
|
navigator.clipboard.writeText('Authorization: Bearer YOUR_API_KEY')
|
|
.then(function() { lt.toast.success('Copied!'); })
|
|
.catch(function() { lt.toast.error('Copy failed'); });
|
|
break;
|
|
}
|
|
});
|
|
|
|
document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
|
|
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, scope: keyScope })
|
|
.then(function (data) {
|
|
if (data.success) {
|
|
document.getElementById('newKeyValue').value = data.api_key;
|
|
document.getElementById('newKeyDisplay').classList.remove('is-hidden');
|
|
document.getElementById('keyName').value = '';
|
|
lt.toast.success('API key generated!');
|
|
setTimeout(function () { location.reload(); }, 5000);
|
|
} else {
|
|
lt.toast.error(data.error || 'Failed to generate API key');
|
|
}
|
|
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
|
|
});
|
|
|
|
function copyApiKey() {
|
|
var val = document.getElementById('newKeyValue').value;
|
|
lt.clipboard.copy(val).then(function (ok) {
|
|
if (ok) lt.toast.success('Copied to clipboard!');
|
|
else lt.toast.error('Copy failed — select the key manually');
|
|
}).catch(function () {
|
|
lt.toast.error('Copy failed — select the key manually');
|
|
});
|
|
}
|
|
|
|
function revokeKey(keyId) {
|
|
showConfirmModal('Revoke API Key', 'Revoke this API key? This cannot be undone.', 'error', function () {
|
|
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
|
|
.then(function (data) {
|
|
if (data.success) { lt.toast.success('API key revoked'); location.reload(); }
|
|
else lt.toast.error(data.error || 'Failed to revoke');
|
|
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
|
|
});
|
|
}
|
|
|
|
if (window.lt) lt.keys.initDefaults();
|
|
</script>
|
|
|
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|