add admin api management

This commit is contained in:
jigoong
2026-02-24 23:29:20 +07:00
parent c89891f4dc
commit 649473d2cc
2 changed files with 205 additions and 12 deletions

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from fastapi import HTTPException, Request, status
from sqladmin import Admin, ModelView
from sqladmin.authentication import AuthenticationBackend
from starlette.responses import RedirectResponse
from starlette.responses import RedirectResponse, JSONResponse
from sqlalchemy.orm import sessionmaker
from wtforms import StringField
from wtforms import StringField, TextAreaField
from wtforms.validators import Optional
from app.core.config import settings
@@ -39,27 +39,60 @@ class ApiClientAdmin(ModelView, model=ApiClient):
class ApiKeyAdmin(ModelView, model=ApiKey):
form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at]
column_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.is_active, ApiKey.created_at]
column_details_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.permissions, ApiKey.is_active, ApiKey.created_at]
details_template = "apikey_details.html"
form_extra_fields = {
"plain_key": StringField("Plain Key", validators=[Optional()]),
"permissions_csv": StringField("Permissions (comma)", validators=[Optional()]),
"permissions": TextAreaField(
"Permissions (JSON Array)",
validators=[Optional()],
description='Example: ["feed.waiting-time:write", "feed.opd-checkpoint:write"]',
render_kw={"placeholder": '["feed.waiting-time:write"]', "rows": 3}
),
}
async def on_model_change(self, data: dict, model: ApiKey, is_created: bool, request: Request) -> None:
plain_key = data.get("plain_key")
if plain_key:
import json
# Handle permissions from textarea (JSON format)
permissions_str = data.get("permissions")
if permissions_str:
try:
perms = json.loads(permissions_str)
if isinstance(perms, list):
model.permissions = perms
else:
raise ValueError("Permissions must be a JSON array")
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid permissions format: {str(e)}. Use JSON array like: [\"feed.waiting-time:write\"]"
)
# Auto-generate key for new records if not provided
if is_created and not model.key_hash:
plain_key = generate_api_key()
model.key_prefix = get_prefix(plain_key)
model.key_hash = hash_api_key(plain_key)
permissions_csv = data.get("permissions_csv")
if permissions_csv is not None:
perms = [p.strip() for p in permissions_csv.split(",") if p.strip()]
model.permissions = perms
# Store in session for display after creation
request.session[f"new_api_key_{model.client_id}"] = plain_key
def mount_admin(app):
import os
auth_backend = AdminAuth(secret_key=settings.ADMIN_SECRET_KEY)
admin = Admin(app=app, engine=engine, authentication_backend=auth_backend)
# Configure templates directory
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
admin = Admin(
app=app,
engine=engine,
authentication_backend=auth_backend,
templates_dir=templates_dir
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
@@ -105,3 +138,67 @@ def mount_admin(app):
return {"key_id": api_key.id, "api_key": plain_key, "permissions": perms}
finally:
db.close()
@app.post("/admin/api-keys/{key_id}/regenerate")
async def _admin_regenerate_api_key(request: Request, key_id: int):
"""Regenerate API key - creates new key while preserving permissions and other settings."""
if not request.session.get("admin"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
db = SessionLocal()
try:
api_key = db.get(ApiKey, key_id)
if not api_key:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found")
# Generate new key
plain_key = generate_api_key()
api_key.key_prefix = get_prefix(plain_key)
api_key.key_hash = hash_api_key(plain_key)
db.commit()
db.refresh(api_key)
return JSONResponse({
"success": True,
"key_id": api_key.id,
"api_key": plain_key,
"key_prefix": api_key.key_prefix,
"permissions": api_key.permissions,
"message": "API key regenerated successfully. Please save this key - it won't be shown again!"
})
finally:
db.close()
@app.get("/admin/api-keys/{key_id}/view")
async def _admin_view_api_key(request: Request, key_id: int):
"""View API key from session (only works for newly created/regenerated keys)."""
if not request.session.get("admin"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
db = SessionLocal()
try:
api_key = db.get(ApiKey, key_id)
if not api_key:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found")
# Check if there's a stored key in session
session_key = f"new_api_key_{api_key.client_id}"
plain_key = request.session.get(session_key)
if plain_key:
# Clear from session after viewing
request.session.pop(session_key, None)
return JSONResponse({
"success": True,
"api_key": plain_key,
"key_prefix": api_key.key_prefix,
"message": "This is the only time you can view this key!"
})
else:
return JSONResponse({
"success": False,
"message": "API key cannot be retrieved. Keys can only be viewed once after creation or regeneration."
}, status_code=400)
finally:
db.close()

View File

@@ -0,0 +1,96 @@
{% extends "sqladmin/details.html" %}
{% block content %}
{{ super() }}
<div class="card mt-3">
<div class="card-header">
<h5>API Key Actions</h5>
</div>
<div class="card-body">
<button type="button" class="btn btn-warning me-2" onclick="regenerateApiKey({{ model.id }})">
<i class="fa fa-refresh"></i> Regenerate API Key
</button>
<button type="button" class="btn btn-info" onclick="viewApiKey({{ model.id }})">
<i class="fa fa-eye"></i> View API Key (if just created)
</button>
<div id="apiKeyResult" class="mt-3" style="display: none;">
<div class="alert alert-warning">
<h6>⚠️ Important: Save this key immediately!</h6>
<p>This key will only be shown once and cannot be retrieved later.</p>
<div class="input-group">
<input type="text" id="apiKeyValue" class="form-control" readonly>
<button class="btn btn-primary" onclick="copyApiKey()">
<i class="fa fa-copy"></i> Copy
</button>
</div>
<small id="apiKeyMessage" class="text-muted mt-2 d-block"></small>
</div>
</div>
</div>
</div>
<script>
async function regenerateApiKey(keyId) {
if (!confirm('Are you sure you want to regenerate this API key? The old key will stop working immediately!')) {
return;
}
try {
const response = await fetch(`/apiservice/admin/api-keys/${keyId}/regenerate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (data.success) {
document.getElementById('apiKeyValue').value = data.api_key;
document.getElementById('apiKeyMessage').textContent = data.message;
document.getElementById('apiKeyResult').style.display = 'block';
alert('✅ API Key regenerated successfully!\n\nNew Key: ' + data.api_key + '\n\nPlease copy and save it now!');
} else {
alert('❌ Error: ' + (data.message || 'Failed to regenerate API key'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
async function viewApiKey(keyId) {
try {
const response = await fetch(`/apiservice/admin/api-keys/${keyId}/view`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (data.success) {
document.getElementById('apiKeyValue').value = data.api_key;
document.getElementById('apiKeyMessage').textContent = data.message;
document.getElementById('apiKeyResult').style.display = 'block';
alert('✅ API Key retrieved!\n\nKey: ' + data.api_key + '\n\nPlease copy and save it now!');
} else {
alert(' ' + data.message);
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
function copyApiKey() {
const input = document.getElementById('apiKeyValue');
input.select();
document.execCommand('copy');
alert('✅ API Key copied to clipboard!');
}
</script>
{% endblock %}