Unifi-Voucher-Tool/admin/api_keys.php
Friederich Loheide 7f1d93debd Barrierefreiheit und mobile Darstellung
- Kontrast: gedämpfter Text war mit 3,1:1 unter WCAG AA, jetzt 4,9:1
  (hell) bzw. 6,4:1 (dunkel)
- Sprungmarke „Zum Inhalt springen" in Admin-Shell und Voucher-Seite
- aria-label für alle reinen Icon-Schaltflächen (Theme, Menü, Abmelden,
  Zeilenaktionen), aria-current auf dem aktiven Navigationspunkt,
  role="group" für den Sprachumschalter
- 194 dekorative Icons mit aria-hidden versehen, damit Screenreader sie
  nicht vorlesen
- Toast-Container als aria-live-Bereich ausgezeichnet
- prefers-reduced-motion schaltet Animationen und Übergänge ab

Tabellen (Benutzer, Vouchers, Profile, Audit-Log, Dashboard, API-Keys)
werden unter 720 px zu Karten: die Spaltenüberschrift steht per
data-label vor dem Wert, statt horizontal zu scrollen.

Datums- und Zeitformat in der Voucher-Liste folgen jetzt der gewählten
Sprache statt fest de-DE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:38:08 +00:00

157 lines
7.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/ApiKey.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
$newKey = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$name = trim($_POST['name'] ?? '');
if ($name === '') {
$error = __('error_name_req');
} else {
$scope = ($_POST['scope'] ?? 'write') === 'read' ? 'read' : 'write';
$rate = max(0, (int)($_POST['rate_limit'] ?? 0));
$k = ApiKey::generate();
$db->execute(
"INSERT INTO api_keys (name, key_prefix, key_hash, scope, rate_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)",
[$name, $k['prefix'], $k['hash'], $scope, $rate, $_SESSION['user_id']]
);
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
$newKey = $k['plain'];
$success = __('api_created_once');
}
}
}
if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
if ($row) {
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
$success = __('api_status_updated');
}
}
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
$success = __('api_deleted');
}
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
$csrf = $auth->getCsrfToken();
$currentPage = 'api_keys';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('api_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('api_title') ?></h1>
<p class="page-subtitle"><?= __('api_subtitle') ?></p>
</div>
</div>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if ($newKey): ?>
<div class="card">
<h2><?= __('api_new_key') ?></h2>
<p class="muted"><?= __('api_new_key_hint') ?></p>
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
</div>
<?php endif; ?>
<div class="card">
<h2><?= __('api_create_title') ?></h2>
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div style="flex:2;min-width:200px;">
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_name') ?></label>
<input class="input" type="text" name="name" placeholder="<?= __('api_name_placeholder') ?>" required>
</div>
<div style="flex:1;min-width:130px;">
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_scope') ?></label>
<select class="input" name="scope">
<option value="write"><?= __('api_scope_write') ?></option>
<option value="read"><?= __('api_scope_read') ?></option>
</select>
</div>
<div style="flex:1;min-width:120px;">
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('api_label_limit') ?></label>
<input class="input" type="number" name="rate_limit" min="0" value="0" title="<?= __('api_limit_title') ?>">
</div>
<button class="btn btn-primary" type="submit" name="create_key"><?= __('btn_create') ?></button>
</form>
</div>
<div class="card">
<h2><?= __('api_existing') ?></h2>
<?php if (empty($keys)): ?>
<p class="muted"><?= __('api_none') ?></p>
<?php else: ?>
<div class="table-container">
<table class="table-stack">
<tr><th><?= __('label_name') ?></th><th><?= __('api_col_prefix') ?></th><th><?= __('api_col_scope') ?></th><th><?= __('api_col_limit') ?></th><th><?= __('label_status') ?></th><th><?= __('api_col_last_used') ?></th><th><?= __('api_col_created_by') ?></th><th></th></tr>
<?php foreach ($keys as $k): ?>
<tr>
<td data-label="<?= __('label_name') ?>"><?= htmlspecialchars($k['name']) ?></td>
<td data-label="<?= __('api_col_prefix') ?>"><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
<td data-label="<?= __('api_col_scope') ?>"><?= ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?></td>
<td data-label="<?= __('api_col_limit') ?>"><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
<td data-label="<?= __('label_status') ?>"><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? __('api_state_active') : __('api_state_blocked') ?></span></td>
<td class="muted" data-label="<?= __('api_col_last_used') ?>"><?= $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '' ?></td>
<td class="muted" data-label="<?= __('api_col_created_by') ?>"><?= htmlspecialchars($k['creator'] ?? '') ?></td>
<td style="text-align:right;white-space:nowrap;">
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? __('api_action_block') : __('api_action_unblock') ?></a>
<a class="a-link" style="color:var(--danger);" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('<?= __('api_delete_confirm') ?>');"><?= __('btn_delete') ?></a>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
</div>
<div class="card">
<h2><?= __('api_usage') ?></h2>
<p class="muted" style="margin-bottom:10px;"><?= __('api_usage_hint') ?> <code>Authorization: Bearer &lt;key&gt;</code> oder <code>X-API-Key: &lt;key&gt;</code>.</p>
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># Voucher erstellen
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
-H "Authorization: Bearer uvt_…" \
-H "Content-Type: application/json" \
-d '{"site_id":1,"name":"API Gast","max_uses":1,"expire_minutes":480}'
# Sites auflisten
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"</pre>
<p class="muted" style="margin-top:12px;"><?= __('api_openapi') ?> <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
</div>
</main>
<script src="../assets/global.js"></script>
</body>
</html>