Compare commits

...

2 Commits

Author SHA1 Message Date
jigoong
3a5f9e9001 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
2026-06-04 18:22:22 +07:00
jigoong
e4d32b86cb feat: add VOC data endpoint (POST /api/v1/voc-data)
- Add VocDataIn schema (date, topic, sub_topic, level, depart_id, dep_name)
- Add RawVocData SQLAlchemy model (rawdata.raw_voc_data, BIGSERIAL PK)
- Add POST /api/v1/voc-data endpoint with voc.data:write permission
- Dual-write to local PostgreSQL + Supabase
- Table auto-created on startup via Base.metadata.create_all()
2026-06-04 18:22:14 +07:00
11 changed files with 873 additions and 24 deletions

View 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
_(เขียนเอง)_

View File

@@ -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(

View File

@@ -9,9 +9,9 @@ from fastapi import APIRouter, Depends
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn, PatientAppointmentIn
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn, PatientAppointmentIn, VocDataIn
from app.core.config import settings
from app.db.models import RawOpdCheckpoint, RawWaitingTime, PatientAppointment
from app.db.models import RawOpdCheckpoint, RawWaitingTime, PatientAppointment, RawVocData
from app.security.dependencies import get_db, require_permission
from app.utils.supabase_client import SupabaseAPIError, upsert_to_supabase_sync
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/api/v1")
PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write"
PERM_FEED_OLD_CHECKPOINT_WRITE = "feed.old-checkpoint:write"
PERM_FEED_PATIENT_APPOINTMENT_WRITE = "feed.patient-appointment:write"
PERM_VOC_DATA_WRITE = "voc.data:write"
def _to_tz(dt):
@@ -304,3 +305,41 @@ def upsert_patient_appointment(
"error": supabase_error,
},
}
@router.post("/voc-data")
def insert_voc_data(
payload: list[VocDataIn],
_: Annotated[object, Depends(require_permission(PERM_VOC_DATA_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = [r.model_dump() for r in payload]
stmt = insert(RawVocData).values(rows)
result = db.execute(stmt)
db.commit()
supabase_rows = [{**r, "date": r["date"].isoformat()} for r in rows]
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} VOC records to Supabase API")
supabase_result = upsert_to_supabase_sync(table="raw_voc_data", data=supabase_rows)
logger.info(f"Successfully sent VOC data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send VOC data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending VOC data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"inserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}

View File

@@ -37,3 +37,12 @@ class PatientAppointmentIn(BaseModel):
doctor_code: str | None = None
period: str | None = None
appointment_type: str | None = None
class VocDataIn(BaseModel):
date: date
topic: str
sub_topic: str
level: str
depart_id: str
dep_name: str | None = None

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -65,6 +65,20 @@ class PatientAppointment(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class RawVocData(Base):
__tablename__ = "raw_voc_data"
__table_args__ = {"schema": "rawdata"}
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
date: Mapped[date] = mapped_column(Date, nullable=False)
topic: Mapped[str] = mapped_column(String(200), nullable=False)
sub_topic: Mapped[str] = mapped_column(String(200), nullable=False)
level: Mapped[str] = mapped_column(String(50), nullable=False)
depart_id: Mapped[str] = mapped_column(String(50), nullable=False)
dep_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ApiClient(Base):
__tablename__ = "api_client"
__table_args__ = {"schema": "fastapi"}

View File

@@ -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

View File

@@ -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):

View 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}

View File

@@ -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,

View 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} &nbsp;·&nbsp; ${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>

View File

@@ -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>