feat: replace SQLAdmin with Keycloak-protected API management page
- Disable SQLAdmin basic auth (comment out mount_admin, statics, redirect) - Add /api-management page (Keycloak admin role required) - Add admin_api_keys.py: REST endpoints for list/create clients and keys - Add api_management.html: manage API clients, keys, permissions with copy-once key display - Update index.html: API Management link -> /api-management - Update auth middleware: add /api-management and /admin/users to PROTECTED_PATHS - Add CHANGES-2026-06-04.md dev notes
This commit is contained in:
138
03-apiservice/CHANGES-2026-06-04.md
Normal file
138
03-apiservice/CHANGES-2026-06-04.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Changes — 2026-06-04
|
||||||
|
|
||||||
|
## สรุป
|
||||||
|
|
||||||
|
วันนี้เพิ่ม 2 feature ใหม่ใน `03-apiservice`:
|
||||||
|
1. **VOC Data endpoint** — รับข้อมูลข้อร้องเรียน (Voice of Customer) จาก programmatic client
|
||||||
|
2. **API Management page** — หน้าจัดการ API clients/keys ด้วย Keycloak admin auth แทน SQLAdmin basic auth เดิม
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 1 — VOC Data Endpoint
|
||||||
|
|
||||||
|
### ไฟล์ที่แก้ไข
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
|------|----------------|
|
||||||
|
| `app/api/v1/schemas.py` | เพิ่ม `VocDataIn` schema |
|
||||||
|
| `app/db/models.py` | เพิ่ม `RawVocData` model (table: `rawdata.raw_voc_data`) |
|
||||||
|
| `app/api/v1/routes.py` | เพิ่ม `POST /api/v1/voc-data` endpoint |
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/voc-data
|
||||||
|
Authorization: Bearer <api-key> (permission required: voc.data:write)
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request body** (batch array):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-06-04",
|
||||||
|
"topic": "บริการพยาบาล",
|
||||||
|
"sub_topic": "ความรวดเร็ว",
|
||||||
|
"level": "3",
|
||||||
|
"depart_id": "OPD01",
|
||||||
|
"dep_name": "ผู้ป่วยนอก"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"inserted": 1,
|
||||||
|
"rowcount": 1,
|
||||||
|
"supabase": { "success": true, "result": {...}, "error": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE rawdata.raw_voc_data (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
topic VARCHAR(200) NOT NULL,
|
||||||
|
sub_topic VARCHAR(200) NOT NULL,
|
||||||
|
level VARCHAR(50) NOT NULL,
|
||||||
|
depart_id VARCHAR(50) NOT NULL,
|
||||||
|
dep_name VARCHAR(200),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` เป็น BIGSERIAL auto-increment — server generate เอง, client ไม่ต้องส่ง
|
||||||
|
- ทุก record ที่ส่งมาจะ INSERT เพิ่มเสมอ (ไม่มี upsert/on_conflict)
|
||||||
|
- สร้าง table อัตโนมัติจาก `Base.metadata.create_all()` ตอน startup
|
||||||
|
- Dual-write ไปยัง Supabase (`raw_voc_data` table) เหมือน endpoint อื่นๆ
|
||||||
|
|
||||||
|
### Deploy status
|
||||||
|
|
||||||
|
✅ Deploy แล้ว บน server .8 — table `rawdata.raw_voc_data` ถูกสร้างแล้ว
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 2 — API Management Page (Keycloak auth)
|
||||||
|
|
||||||
|
### ปัญหาเดิม
|
||||||
|
|
||||||
|
SQLAdmin panel (`/admin/`) ใช้ basic auth (username/password จาก `.env`) แยกต่างหากจาก Keycloak ซึ่งเป็น auth system หลักของระบบ
|
||||||
|
|
||||||
|
### การแก้ไข
|
||||||
|
|
||||||
|
ปิด SQLAdmin และสร้างหน้าจัดการ API keys ใหม่ที่ใช้ Keycloak admin auth แทน
|
||||||
|
|
||||||
|
### ไฟล์ที่แก้ไข
|
||||||
|
|
||||||
|
| ไฟล์ | การเปลี่ยนแปลง |
|
||||||
|
|------|----------------|
|
||||||
|
| `app/main.py` | ลบ SQLAdmin imports/mounts (`sqladmin`, statics, `mount_admin`) |
|
||||||
|
| `app/admin.py` | Comment out `/admin` redirect route |
|
||||||
|
| `app/middleware/auth_middleware.py` | เพิ่ม `/api-management`, `/admin/users` ใน `PROTECTED_PATHS` |
|
||||||
|
| `app/routes/pages.py` | เพิ่ม `GET /api-management` route |
|
||||||
|
| `app/templates/index.html` | เปลี่ยน link จาก `/admin/` → `/api-management` |
|
||||||
|
|
||||||
|
### ไฟล์ใหม่
|
||||||
|
|
||||||
|
| ไฟล์ | คำอธิบาย |
|
||||||
|
|------|---------|
|
||||||
|
| `app/routes/admin_api_keys.py` | REST endpoints สำหรับจัดการ API clients/keys (Keycloak admin auth) |
|
||||||
|
| `app/templates/api_management.html` | หน้าจัดการ API clients และ keys |
|
||||||
|
|
||||||
|
### Endpoints ใหม่ (ทั้งหมดต้องการ Keycloak admin role)
|
||||||
|
|
||||||
|
| Method | Path | คำอธิบาย |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/admin/api-keys/clients` | List ทุก API client พร้อม nested keys |
|
||||||
|
| POST | `/admin/api-keys/clients` | สร้าง API client ใหม่ |
|
||||||
|
| POST | `/admin/api-keys/generate` | สร้าง API key (คืน plaintext ครั้งเดียว) |
|
||||||
|
| POST | `/admin/api-keys/{id}/regenerate` | Regenerate key (คืน plaintext ครั้งเดียว) |
|
||||||
|
| PATCH | `/admin/api-keys/{id}/toggle` | Toggle is_active |
|
||||||
|
|
||||||
|
### Features ของหน้า `/api-management`
|
||||||
|
|
||||||
|
- Stats: จำนวน clients, total keys, active keys
|
||||||
|
- สร้าง API Client พร้อมกำหนดชื่อ
|
||||||
|
- สร้าง API Key พร้อมกำหนด permissions เป็น JSON array
|
||||||
|
- แสดง plaintext key ใน modal ครั้งเดียวหลัง generate/regenerate พร้อมปุ่ม Copy
|
||||||
|
- Activate/Deactivate key
|
||||||
|
- เข้าถึงได้ที่ `https://ai.sriphat.com/apiservice/api-management` (ต้อง login ด้วย Keycloak admin account)
|
||||||
|
|
||||||
|
### Deploy status
|
||||||
|
|
||||||
|
⏳ ยังไม่ deploy — รอ review ก่อน
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blockers / สิ่งที่ค้างอยู่
|
||||||
|
|
||||||
|
- **Airflow API token** ยังไม่ได้ config → Finance upload จะ set status=error หลัง upload สำเร็จ (ไฟล์อัปขึ้น MinIO ได้ แต่ trigger DAG ไม่ได้)
|
||||||
|
- **VOC API key** — ยังต้องสร้าง ApiClient + ApiKey ที่มี permission `voc.data:write` สำหรับ client ที่จะส่งข้อมูล (ทำได้หลัง deploy Feature 2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Decision & Lesson
|
||||||
|
_(เขียนเอง)_
|
||||||
@@ -91,10 +91,11 @@ def mount_admin(app):
|
|||||||
admin.add_view(ApiClientAdmin)
|
admin.add_view(ApiClientAdmin)
|
||||||
admin.add_view(ApiKeyAdmin)
|
admin.add_view(ApiKeyAdmin)
|
||||||
|
|
||||||
@app.get("/admin")
|
# SQLAdmin /admin route disabled — replaced by Keycloak-protected /api-management page
|
||||||
async def _admin_redirect(request: Request):
|
# @app.get("/admin")
|
||||||
root_path = request.scope.get("root_path") or ""
|
# async def _admin_redirect(request: Request):
|
||||||
return RedirectResponse(url=f"{root_path}/admin/")
|
# root_path = request.scope.get("root_path") or ""
|
||||||
|
# return RedirectResponse(url=f"{root_path}/admin/")
|
||||||
|
|
||||||
@app.post("/admin/api-keys/generate")
|
@app.post("/admin/api-keys/generate")
|
||||||
async def _admin_generate_api_key(
|
async def _admin_generate_api_key(
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from starlette.datastructures import Headers
|
from starlette.datastructures import Headers
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
import sqladmin
|
|
||||||
|
|
||||||
from app.admin import mount_admin
|
|
||||||
from app.api.v1.routes import router as v1_router
|
from app.api.v1.routes import router as v1_router
|
||||||
from app.routes.pages import router as pages_router
|
from app.routes.pages import router as pages_router
|
||||||
from app.routes.auth import router as auth_router
|
from app.routes.auth import router as auth_router
|
||||||
from app.routes.admin_users import router as admin_users_router
|
from app.routes.admin_users import router as admin_users_router
|
||||||
|
from app.routes.admin_api_keys import router as admin_api_keys_router
|
||||||
from app.middleware.auth_middleware import WebAuthenticationMiddleware
|
from app.middleware.auth_middleware import WebAuthenticationMiddleware
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.init_db import init_db
|
from app.db.init_db import init_db
|
||||||
@@ -26,7 +22,6 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logging.getLogger("uvicorn.error").setLevel(logging.DEBUG)
|
logging.getLogger("uvicorn.error").setLevel(logging.DEBUG)
|
||||||
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||||
logging.getLogger("sqladmin").setLevel(logging.DEBUG)
|
|
||||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,9 +63,6 @@ async def lifespan(_: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
sqladmin_dir = os.path.dirname(sqladmin.__file__)
|
|
||||||
statics_path = os.path.join(sqladmin_dir, "statics")
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan)
|
app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan)
|
||||||
|
|
||||||
# Add exception handler to log all errors with traceback
|
# Add exception handler to log all errors with traceback
|
||||||
@@ -105,7 +97,4 @@ app.include_router(v1_router) # API endpoints - use API Key auth
|
|||||||
app.include_router(pages_router) # Web pages - use Keycloak auth
|
app.include_router(pages_router) # Web pages - use Keycloak auth
|
||||||
app.include_router(auth_router) # Authentication routes
|
app.include_router(auth_router) # Authentication routes
|
||||||
app.include_router(admin_users_router) # Admin user management API
|
app.include_router(admin_users_router) # Admin user management API
|
||||||
|
app.include_router(admin_api_keys_router) # API key management - use Keycloak admin auth
|
||||||
app.mount("/admin/statics", StaticFiles(directory=statics_path), name="admin_statics")
|
|
||||||
app.mount("/apiservice/admin/statics", StaticFiles(directory=statics_path), name="proxy_admin_statics")
|
|
||||||
mount_admin(app)
|
|
||||||
|
|||||||
@@ -34,14 +34,16 @@ class WebAuthenticationMiddleware(BaseHTTPMiddleware):
|
|||||||
"/docs",
|
"/docs",
|
||||||
"/redoc",
|
"/redoc",
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
"/data-management"
|
"/data-management",
|
||||||
|
"/api-management",
|
||||||
|
"/admin/users",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Routes that are excluded from user authentication
|
# Routes that are excluded from user authentication
|
||||||
EXCLUDED_PATHS = [
|
EXCLUDED_PATHS = [
|
||||||
"/auth", # Authentication endpoints
|
"/auth", # Authentication endpoints
|
||||||
"/api/v1", # API endpoints (use API Key)
|
"/api/v1", # API endpoints (use API Key)
|
||||||
"/admin", # SQLAdmin (has own auth)
|
"/admin", # Admin API endpoints (use require_role dependency)
|
||||||
]
|
]
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
|||||||
141
03-apiservice/app/routes/admin_api_keys.py
Normal file
141
03-apiservice/app/routes/admin_api_keys.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
API Client and API Key management endpoints (Admin only)
|
||||||
|
Uses Keycloak admin role authentication — same pattern as admin_users.py
|
||||||
|
"""
|
||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.db.models import ApiClient, ApiKey
|
||||||
|
from app.security.permissions import require_role, Roles
|
||||||
|
from app.security.api_key import generate_api_key, hash_api_key, encrypt_api_key, get_prefix
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/api-keys", tags=["admin-api-keys"])
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeySchema(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str | None = None
|
||||||
|
key_prefix: str
|
||||||
|
permissions: list
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClientSchema(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
is_active: bool
|
||||||
|
api_keys: List[ApiKeySchema] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClientCreateSchema(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyCreateSchema(BaseModel):
|
||||||
|
client_id: int
|
||||||
|
name: str | None = None
|
||||||
|
permissions: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clients", response_model=List[ApiClientSchema])
|
||||||
|
async def list_clients(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_role(Roles.ADMIN)),
|
||||||
|
):
|
||||||
|
"""List all API clients with their keys (Admin only)"""
|
||||||
|
return db.query(ApiClient).order_by(ApiClient.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clients", response_model=ApiClientSchema)
|
||||||
|
async def create_client(
|
||||||
|
data: ApiClientCreateSchema,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_role(Roles.ADMIN)),
|
||||||
|
):
|
||||||
|
"""Create a new API client (Admin only)"""
|
||||||
|
existing = db.query(ApiClient).filter(ApiClient.name == data.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Client name already exists")
|
||||||
|
client = ApiClient(name=data.name, is_active=True)
|
||||||
|
db.add(client)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(client)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_key(
|
||||||
|
data: ApiKeyCreateSchema,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_role(Roles.ADMIN)),
|
||||||
|
):
|
||||||
|
"""Generate a new API key for a client (Admin only). Returns plaintext key once."""
|
||||||
|
client = db.get(ApiClient, data.client_id)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=404, detail="Client not found")
|
||||||
|
|
||||||
|
plain_key = generate_api_key()
|
||||||
|
api_key = ApiKey(
|
||||||
|
client_id=data.client_id,
|
||||||
|
name=data.name,
|
||||||
|
key_prefix=get_prefix(plain_key),
|
||||||
|
key_hash=hash_api_key(plain_key),
|
||||||
|
encrypted_key=encrypt_api_key(plain_key),
|
||||||
|
permissions=data.permissions,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(api_key)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(api_key)
|
||||||
|
logger.info(f"Admin {current_user.get('username')} created API key {api_key.id} for client {client.name}")
|
||||||
|
return {"key_id": api_key.id, "api_key": plain_key, "key_prefix": api_key.key_prefix, "permissions": data.permissions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{key_id}/regenerate")
|
||||||
|
async def regenerate_key(
|
||||||
|
key_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_role(Roles.ADMIN)),
|
||||||
|
):
|
||||||
|
"""Regenerate an API key — preserves permissions, returns new plaintext once (Admin only)"""
|
||||||
|
api_key = db.get(ApiKey, key_id)
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=404, detail="API Key not found")
|
||||||
|
|
||||||
|
plain_key = generate_api_key()
|
||||||
|
api_key.key_prefix = get_prefix(plain_key)
|
||||||
|
api_key.key_hash = hash_api_key(plain_key)
|
||||||
|
api_key.encrypted_key = encrypt_api_key(plain_key)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(api_key)
|
||||||
|
logger.info(f"Admin {current_user.get('username')} regenerated API key {key_id}")
|
||||||
|
return {"key_id": api_key.id, "api_key": plain_key, "key_prefix": api_key.key_prefix, "permissions": api_key.permissions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{key_id}/toggle")
|
||||||
|
async def toggle_key(
|
||||||
|
key_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_role(Roles.ADMIN)),
|
||||||
|
):
|
||||||
|
"""Toggle API key active/inactive (Admin only)"""
|
||||||
|
api_key = db.get(ApiKey, key_id)
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=404, detail="API Key not found")
|
||||||
|
api_key.is_active = not api_key.is_active
|
||||||
|
db.commit()
|
||||||
|
return {"key_id": key_id, "is_active": api_key.is_active}
|
||||||
@@ -95,6 +95,22 @@ async def admin_users_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api-management", response_class=HTMLResponse)
|
||||||
|
async def api_management_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(require_role(Roles.ADMIN))
|
||||||
|
):
|
||||||
|
"""API Key management page - Admin only"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"api_management.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"root_path": settings.ROOT_PATH,
|
||||||
|
"user": current_user
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/data-management/finance/upload")
|
@router.post("/data-management/finance/upload")
|
||||||
async def upload_finance_file(
|
async def upload_finance_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
491
03-apiservice/app/templates/api_management.html
Normal file
491
03-apiservice/app/templates/api_management.html
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API Management - Admin</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
h1 { color: #333; font-size: 32px; }
|
||||||
|
.user-info { display: flex; align-items: center; gap: 15px; }
|
||||||
|
.role-badge {
|
||||||
|
padding: 5px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.alert-success { background: #51cf66; color: white; }
|
||||||
|
.alert-error { background: #ff6b6b; color: white; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-number { font-size: 36px; font-weight: bold; margin-bottom: 5px; }
|
||||||
|
.stat-label { font-size: 14px; opacity: 0.9; }
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 30px 0 15px;
|
||||||
|
}
|
||||||
|
.section-header h2 { font-size: 22px; color: #333; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #667eea; color: white; }
|
||||||
|
.btn-primary:hover { background: #5568d3; transform: translateY(-1px); }
|
||||||
|
.btn-success { background: #51cf66; color: white; }
|
||||||
|
.btn-success:hover { background: #40c057; transform: translateY(-1px); }
|
||||||
|
.btn-warning { background: #fcc419; color: #333; }
|
||||||
|
.btn-warning:hover { background: #fab005; transform: translateY(-1px); }
|
||||||
|
.btn-danger { background: #ff6b6b; color: white; }
|
||||||
|
.btn-danger:hover { background: #ee5a52; transform: translateY(-1px); }
|
||||||
|
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
.client-card {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.client-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.client-name { font-size: 18px; font-weight: 600; color: #333; }
|
||||||
|
.client-meta { font-size: 13px; color: #666; margin-top: 3px; }
|
||||||
|
.client-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
th { font-size: 12px; font-weight: 600; color: #666; text-transform: uppercase; letter-spacing: 0.5px; background: #fafafa; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: #f8f9ff; }
|
||||||
|
|
||||||
|
.status-active { color: #51cf66; font-weight: 600; }
|
||||||
|
.status-inactive { color: #ff6b6b; font-weight: 600; }
|
||||||
|
|
||||||
|
.key-prefix {
|
||||||
|
font-family: monospace;
|
||||||
|
background: #f1f3f5;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.perm-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e7f5ff;
|
||||||
|
color: #1c7ed6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 2px 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-keys {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.modal h3 { font-size: 20px; margin-bottom: 20px; color: #333; }
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-group label { display: block; font-size: 14px; font-weight: 600; color: #555; margin-bottom: 6px; }
|
||||||
|
.form-group input, .form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus, .form-group textarea:focus { border-color: #667eea; }
|
||||||
|
.form-group small { font-size: 12px; color: #888; margin-top: 4px; display: block; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
|
||||||
|
|
||||||
|
.key-result-box {
|
||||||
|
background: #f1f3f5;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.key-warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading { text-align: center; padding: 40px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ root_path }}/" class="back-link">← Back to Dashboard</a>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔑 API Management</h1>
|
||||||
|
{% if user %}
|
||||||
|
<div class="user-info">
|
||||||
|
<span>{{ user.name or user.username }}</span>
|
||||||
|
{% if user.roles %}
|
||||||
|
{% for role in user.roles %}
|
||||||
|
<span class="role-badge">{{ role }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alertContainer"></div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="totalClients">-</div>
|
||||||
|
<div class="stat-label">API Clients</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="totalKeys">-</div>
|
||||||
|
<div class="stat-label">Total Keys</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="activeKeys">-</div>
|
||||||
|
<div class="stat-label">Active Keys</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>API Clients</h2>
|
||||||
|
<button class="btn btn-primary" onclick="openNewClientModal()">+ New Client</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="clientsContainer">
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: New Client -->
|
||||||
|
<div class="modal-overlay" id="newClientModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>New API Client</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Client Name</label>
|
||||||
|
<input type="text" id="newClientName" placeholder="e.g. hospital-erp" />
|
||||||
|
<small>Unique identifier for this client system</small>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" onclick="closeModal('newClientModal')">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="createClient()">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: New Key -->
|
||||||
|
<div class="modal-overlay" id="newKeyModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Generate API Key</h3>
|
||||||
|
<input type="hidden" id="newKeyClientId" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Key Name (optional)</label>
|
||||||
|
<input type="text" id="newKeyName" placeholder="e.g. production" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Permissions</label>
|
||||||
|
<textarea id="newKeyPermissions" rows="4" placeholder='["voc.data:write", "feed.checkpoint:write"]'></textarea>
|
||||||
|
<small>JSON array of permission strings</small>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" onclick="closeModal('newKeyModal')">Cancel</button>
|
||||||
|
<button class="btn btn-success" onclick="generateKey()">Generate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Show Key -->
|
||||||
|
<div class="modal-overlay" id="keyResultModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>🔑 API Key Generated</h3>
|
||||||
|
<div class="key-warning">⚠️ Copy this key now — it will NOT be shown again after closing this dialog.</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key</label>
|
||||||
|
<div class="key-result-box" id="keyResultValue"></div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="copyKey()" style="margin-top:8px;">Copy to Clipboard</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Permissions</label>
|
||||||
|
<div id="keyResultPerms"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="closeKeyResult()">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const rootPath = "{{ root_path }}";
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${rootPath}/admin/api-keys/clients`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const clients = await res.json();
|
||||||
|
|
||||||
|
const totalKeys = clients.reduce((s, c) => s + c.api_keys.length, 0);
|
||||||
|
const activeKeys = clients.reduce((s, c) => s + c.api_keys.filter(k => k.is_active).length, 0);
|
||||||
|
document.getElementById('totalClients').textContent = clients.length;
|
||||||
|
document.getElementById('totalKeys').textContent = totalKeys;
|
||||||
|
document.getElementById('activeKeys').textContent = activeKeys;
|
||||||
|
|
||||||
|
const container = document.getElementById('clientsContainer');
|
||||||
|
if (clients.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-keys">No API clients yet. Create one to get started.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = clients.map(client => `
|
||||||
|
<div class="client-card">
|
||||||
|
<div class="client-header">
|
||||||
|
<div>
|
||||||
|
<div class="client-name">${escapeHtml(client.name)}</div>
|
||||||
|
<div class="client-meta">ID: ${client.id} · ${client.api_keys.length} key(s)</div>
|
||||||
|
</div>
|
||||||
|
<div class="client-actions">
|
||||||
|
<span class="${client.is_active ? 'status-active' : 'status-inactive'}">${client.is_active ? '● Active' : '● Inactive'}</span>
|
||||||
|
<button class="btn btn-success btn-sm" onclick="openNewKeyModal(${client.id})">+ Add Key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${client.api_keys.length === 0
|
||||||
|
? '<div class="empty-keys">No API keys yet.</div>'
|
||||||
|
: `<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>ID</th><th>Name</th><th>Prefix</th><th>Permissions</th><th>Status</th><th>Created</th><th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${client.api_keys.map(key => `
|
||||||
|
<tr>
|
||||||
|
<td>${key.id}</td>
|
||||||
|
<td>${key.name ? escapeHtml(key.name) : '-'}</td>
|
||||||
|
<td><span class="key-prefix">${escapeHtml(key.key_prefix)}...</span></td>
|
||||||
|
<td>${key.permissions.map(p => `<span class="perm-tag">${escapeHtml(p)}</span>`).join('') || '<span style="color:#aaa">none</span>'}</td>
|
||||||
|
<td class="${key.is_active ? 'status-active' : 'status-inactive'}">${key.is_active ? '● Active' : '● Inactive'}</td>
|
||||||
|
<td>${formatDate(key.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-warning btn-sm" onclick="regenerateKey(${key.id})">Regenerate</button>
|
||||||
|
<button class="btn btn-sm" style="background:#dee2e6" onclick="toggleKey(${key.id})">${key.is_active ? 'Deactivate' : 'Activate'}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('clientsContainer').innerHTML = `<div class="empty-keys" style="color:#ff6b6b">Error: ${escapeHtml(err.message)}</div>`;
|
||||||
|
showAlert('Failed to load clients: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewClientModal() {
|
||||||
|
document.getElementById('newClientName').value = '';
|
||||||
|
document.getElementById('newClientModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
const name = document.getElementById('newClientName').value.trim();
|
||||||
|
if (!name) return showAlert('Client name is required', 'error');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${rootPath}/admin/api-keys/clients`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
|
||||||
|
closeModal('newClientModal');
|
||||||
|
showAlert(`Client "${name}" created`, 'success');
|
||||||
|
loadClients();
|
||||||
|
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewKeyModal(clientId) {
|
||||||
|
document.getElementById('newKeyClientId').value = clientId;
|
||||||
|
document.getElementById('newKeyName').value = '';
|
||||||
|
document.getElementById('newKeyPermissions').value = '';
|
||||||
|
document.getElementById('newKeyModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateKey() {
|
||||||
|
const clientId = parseInt(document.getElementById('newKeyClientId').value);
|
||||||
|
const name = document.getElementById('newKeyName').value.trim() || null;
|
||||||
|
const permsRaw = document.getElementById('newKeyPermissions').value.trim();
|
||||||
|
let permissions = [];
|
||||||
|
if (permsRaw) {
|
||||||
|
try { permissions = JSON.parse(permsRaw); }
|
||||||
|
catch { return showAlert('Permissions must be a valid JSON array', 'error'); }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${rootPath}/admin/api-keys/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ client_id: clientId, name, permissions })
|
||||||
|
});
|
||||||
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
|
||||||
|
const data = await res.json();
|
||||||
|
closeModal('newKeyModal');
|
||||||
|
showKeyResult(data.api_key, data.permissions);
|
||||||
|
loadClients();
|
||||||
|
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateKey(keyId) {
|
||||||
|
if (!confirm('Regenerate this key? The current key will stop working immediately.')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}/regenerate`, { method: 'POST' });
|
||||||
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
|
||||||
|
const data = await res.json();
|
||||||
|
showKeyResult(data.api_key, data.permissions);
|
||||||
|
loadClients();
|
||||||
|
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleKey(keyId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}/toggle`, { method: 'PATCH' });
|
||||||
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
|
||||||
|
const data = await res.json();
|
||||||
|
showAlert(`Key ${data.is_active ? 'activated' : 'deactivated'}`, 'success');
|
||||||
|
loadClients();
|
||||||
|
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showKeyResult(apiKey, permissions) {
|
||||||
|
document.getElementById('keyResultValue').textContent = apiKey;
|
||||||
|
document.getElementById('keyResultPerms').innerHTML = permissions.length
|
||||||
|
? permissions.map(p => `<span class="perm-tag">${escapeHtml(p)}</span>`).join('')
|
||||||
|
: '<span style="color:#aaa">none</span>';
|
||||||
|
document.getElementById('keyResultModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
const key = document.getElementById('keyResultValue').textContent;
|
||||||
|
navigator.clipboard.writeText(key).then(() => showAlert('Copied to clipboard', 'success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeKeyResult() {
|
||||||
|
closeModal('keyResultModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(message, type) {
|
||||||
|
const el = document.getElementById('alertContainer');
|
||||||
|
el.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
|
||||||
|
setTimeout(() => el.innerHTML = '', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(s) {
|
||||||
|
return new Date(s).toLocaleString('th-TH', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = String(text);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadClients();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -275,6 +275,15 @@
|
|||||||
<p>Manage users and roles (Admin only)</p>
|
<p>Manage users and roles (Admin only)</p>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- API Management (Admin only) -->
|
||||||
|
{% if user and user.roles and 'admin' in user.roles %}
|
||||||
|
<a href="{{ root_path }}/api-management" class="menu-card card-admin">
|
||||||
|
<span class="icon">🔑</span>
|
||||||
|
<h3>API Management</h3>
|
||||||
|
<p>Manage API clients and keys (Admin only)</p>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user