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>
This commit is contained in:
2026-07-15 18:24:50 -04:00
co-authored by Claude Opus 4.8
parent d535557e5a
commit 5cf5aa9591
7 changed files with 211 additions and 20 deletions
+51 -8
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>
@@ -160,8 +202,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;