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(ApiKeyAdmin)
|
||||
|
||||
@app.get("/admin")
|
||||
async def _admin_redirect(request: Request):
|
||||
root_path = request.scope.get("root_path") or ""
|
||||
return RedirectResponse(url=f"{root_path}/admin/")
|
||||
# SQLAdmin /admin route disabled — replaced by Keycloak-protected /api-management page
|
||||
# @app.get("/admin")
|
||||
# async def _admin_redirect(request: Request):
|
||||
# root_path = request.scope.get("root_path") or ""
|
||||
# return RedirectResponse(url=f"{root_path}/admin/")
|
||||
|
||||
@app.post("/admin/api-keys/generate")
|
||||
async def _admin_generate_api_key(
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
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.routes.pages import router as pages_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_api_keys import router as admin_api_keys_router
|
||||
from app.middleware.auth_middleware import WebAuthenticationMiddleware
|
||||
from app.core.config import settings
|
||||
from app.db.init_db import init_db
|
||||
@@ -26,7 +22,6 @@ logging.basicConfig(
|
||||
)
|
||||
logging.getLogger("uvicorn.error").setLevel(logging.DEBUG)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||
logging.getLogger("sqladmin").setLevel(logging.DEBUG)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
|
||||
|
||||
|
||||
@@ -68,9 +63,6 @@ async def lifespan(_: FastAPI):
|
||||
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)
|
||||
|
||||
# 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(auth_router) # Authentication routes
|
||||
app.include_router(admin_users_router) # Admin user management API
|
||||
|
||||
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)
|
||||
app.include_router(admin_api_keys_router) # API key management - use Keycloak admin auth
|
||||
|
||||
@@ -34,14 +34,16 @@ class WebAuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/data-management"
|
||||
"/data-management",
|
||||
"/api-management",
|
||||
"/admin/users",
|
||||
]
|
||||
|
||||
# Routes that are excluded from user authentication
|
||||
EXCLUDED_PATHS = [
|
||||
"/auth", # Authentication endpoints
|
||||
"/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):
|
||||
|
||||
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")
|
||||
async def upload_finance_file(
|
||||
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>
|
||||
</a>
|
||||
{% 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user