feat: API key management endpoints and dashboard panel
Self-serve keys: GET/POST /api/keys, DELETE /api/keys/:id (admins may revoke any key), GET /api/admin/keys for a platform-wide view. Tokens are qrk_-prefixed, stored only as a SHA-256 hash, and returned exactly once at creation. Read-scope keys are rejected with 403 on non-GET requests; key creation and revocation are recorded in the audit log. Dashboard gets a self-serve key panel with copy-once token display, scope badges, and last-used timestamps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1681,6 +1681,72 @@ def import_links():
|
||||
return jsonify({'created': created, 'errors': errors})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# API keys — self-serve programmatic access
|
||||
# Tokens look like qrk_… and are shown exactly once at creation.
|
||||
# Only a SHA-256 hash is stored.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/keys', methods=['GET'])
|
||||
@login_required
|
||||
def list_api_keys():
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT id, name, prefix, scope, created_at, last_used_at, revoked '
|
||||
'FROM api_keys WHERE user_id=? ORDER BY created_at DESC',
|
||||
(session.get('user_id'),)
|
||||
).fetchall()
|
||||
return jsonify({'keys': [dict(r) for r in rows]})
|
||||
|
||||
|
||||
@app.route('/api/keys', methods=['POST'])
|
||||
@login_required
|
||||
def create_api_key():
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get('name') or '').strip()[:64] or 'API key'
|
||||
scope = (data.get('scope') or 'read').lower()
|
||||
if scope not in ('read', 'write'):
|
||||
return jsonify({'error': "scope must be 'read' or 'write'"}), 400
|
||||
token = 'qrk_' + secrets.token_urlsafe(32)
|
||||
key_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
prefix = token[:12]
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
'INSERT INTO api_keys (user_id, name, key_hash, prefix, scope, created_at) VALUES (?,?,?,?,?,?)',
|
||||
(session.get('user_id'), name, key_hash, prefix, scope, now_iso())
|
||||
)
|
||||
key_id = cur.lastrowid
|
||||
log_audit(conn, 'api_key.create', target=prefix, details=f'name={name} scope={scope}')
|
||||
return jsonify({
|
||||
'id': key_id, 'name': name, 'prefix': prefix, 'scope': scope,
|
||||
'token': token, # shown once — not retrievable later
|
||||
}), 201
|
||||
|
||||
|
||||
@app.route('/api/keys/<int:key_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def revoke_api_key(key_id):
|
||||
with get_db() as conn:
|
||||
key = conn.execute('SELECT * FROM api_keys WHERE id=?', (key_id,)).fetchone()
|
||||
if not key or (key['user_id'] != session.get('user_id') and not session.get('is_admin')):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
conn.execute('UPDATE api_keys SET revoked=1 WHERE id=?', (key_id,))
|
||||
log_audit(conn, 'api_key.revoke', target=key['prefix'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/admin/keys', methods=['GET'])
|
||||
@admin_required
|
||||
def admin_list_api_keys():
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT k.id, k.name, k.prefix, k.scope, k.created_at, k.last_used_at, k.revoked, '
|
||||
'u.username FROM api_keys k JOIN users u ON k.user_id=u.id '
|
||||
'ORDER BY k.created_at DESC'
|
||||
).fetchall()
|
||||
return jsonify({'keys': [dict(r) for r in rows]})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Admin — User Management
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
+76
@@ -670,6 +670,25 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
|
||||
<div class="links-list" id="links-list">
|
||||
<div class="empty-state"><div class="empty-icon">🔗</div><p>Loading...</p></div>
|
||||
</div>
|
||||
|
||||
<!-- API keys — programmatic access tokens -->
|
||||
<div class="panel" id="apikeys-panel" style="margin-top:28px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h3 style="margin:0">API KEYS</h3>
|
||||
<button class="btn-sm" onclick="createApiKey()">+ New Key</button>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:12px">
|
||||
Authenticate API requests with <code style="font-family:var(--font-mono)">Authorization: Bearer qrk_…</code> — see the <a href="/api-docs" target="_blank" style="color:var(--accent)">API docs</a>.
|
||||
</div>
|
||||
<div id="apikey-new" style="display:none;border:1px solid var(--accent);border-radius:8px;padding:12px 14px;margin-bottom:12px;font-family:var(--font-mono);font-size:12px">
|
||||
<div style="color:var(--accent);letter-spacing:.06em;margin-bottom:6px">NEW KEY — COPY IT NOW, IT WON'T BE SHOWN AGAIN</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<code id="apikey-token" style="word-break:break-all"></code>
|
||||
<button class="btn-sm" onclick="copyToClipboard(document.getElementById('apikey-token').textContent)">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="apikeys-list"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── QR ── -->
|
||||
@@ -1214,6 +1233,63 @@ function renderUptimeChart(days) {
|
||||
async function loadDashboard() {
|
||||
await Promise.all([loadStats(), loadTags(), loadPlanUsage()]);
|
||||
await loadLinks();
|
||||
// ── API keys ────────────────────────────────────────
|
||||
async function loadApiKeys() {
|
||||
const container = document.getElementById('apikeys-list');
|
||||
try {
|
||||
const d = await (await authFetch(`${BASE}/api/keys`)).json();
|
||||
const keys = d.keys || [];
|
||||
if (!keys.length) {
|
||||
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">No API keys yet.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
|
||||
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
|
||||
<th style="text-align:left;padding:6px 10px">NAME</th>
|
||||
<th style="text-align:left;padding:6px 10px">KEY</th>
|
||||
<th style="text-align:left;padding:6px 10px">SCOPE</th>
|
||||
<th style="text-align:left;padding:6px 10px">CREATED</th>
|
||||
<th style="text-align:left;padding:6px 10px">LAST USED</th>
|
||||
<th style="text-align:right;padding:6px 10px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${keys.map(k=>`
|
||||
<tr style="border-bottom:1px solid var(--border);${k.revoked?'opacity:.45':''}">
|
||||
<td style="padding:8px 10px">${escHtml(k.name)}</td>
|
||||
<td style="padding:8px 10px;color:var(--muted)">${escHtml(k.prefix)}…</td>
|
||||
<td style="padding:8px 10px"><span style="color:${k.scope==='write'?'var(--accent)':'var(--muted)'}">${k.scope.toUpperCase()}</span></td>
|
||||
<td style="padding:8px 10px;color:var(--muted)">${(k.created_at||'').slice(0,10)}</td>
|
||||
<td style="padding:8px 10px;color:var(--muted)">${k.last_used_at?k.last_used_at.slice(0,16).replace('T',' '):'never'}</td>
|
||||
<td style="padding:8px 10px;text-align:right">${k.revoked?'<span style="color:var(--muted);font-size:10px">REVOKED</span>':`<button class="btn-sm danger" onclick="revokeApiKey(${k.id},'${escHtml(k.name)}')">Revoke</button>`}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch(e) {
|
||||
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load keys</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
const name = prompt('Name for this key (e.g. "CI pipeline"):');
|
||||
if (name === null) return;
|
||||
const write = confirm('Allow WRITE access (create/edit/delete links)?\n\nOK = read + write · Cancel = read-only');
|
||||
try {
|
||||
const resp = await authFetch(`${BASE}/api/keys`, {method:'POST', body:JSON.stringify({name: name||'API key', scope: write?'write':'read'})});
|
||||
const d = await resp.json();
|
||||
if (!resp.ok) { toast(d.error||'Failed to create key','error'); return; }
|
||||
document.getElementById('apikey-new').style.display = 'block';
|
||||
document.getElementById('apikey-token').textContent = d.token;
|
||||
toast('API key created — copy it now');
|
||||
loadApiKeys();
|
||||
} catch(e) { toast('Network error','error'); }
|
||||
}
|
||||
|
||||
async function revokeApiKey(id, name) {
|
||||
if (!confirm(`Revoke API key "${name}"? Requests using it will stop working immediately.`)) return;
|
||||
const resp = await authFetch(`${BASE}/api/keys/${id}`, {method:'DELETE'});
|
||||
if (!resp.ok) { toast('Failed to revoke','error'); return; }
|
||||
toast(`Key "${name}" revoked`);
|
||||
loadApiKeys();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
|
||||
Reference in New Issue
Block a user