feat(admin): add api client management UI and backend CRUD/rotate endpoints

This commit is contained in:
Chris
2026-03-30 23:28:27 +08:00
parent ccb99683b8
commit 1d9bdb7daa
7 changed files with 481 additions and 1 deletions

View File

@@ -1,9 +1,12 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.keygen import generate_key from app.core.keygen import generate_key
from app.core.config import get_settings from app.core.config import get_settings
from app.db.session import get_db from app.db.session import get_db
from app.models.api_client import ApiClient
from app.repositories.companies_repo import CompaniesRepository from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository from app.repositories.modules_repo import ModulesRepository
from app.repositories.permission_groups_repo import PermissionGroupsRepository from app.repositories.permission_groups_repo import PermissionGroupsRepository
@@ -38,8 +41,16 @@ from app.schemas.catalog import (
SystemItem, SystemItem,
SystemUpdateRequest, SystemUpdateRequest,
) )
from app.schemas.api_clients import (
ApiClientCreateRequest,
ApiClientCreateResponse,
ApiClientItem,
ApiClientRotateKeyResponse,
ApiClientUpdateRequest,
)
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.admin_guard import require_admin_principal from app.security.admin_guard import require_admin_principal
from app.security.api_client_auth import hash_api_key
from app.services.authentik_admin_service import AuthentikAdminService from app.services.authentik_admin_service import AuthentikAdminService
router = APIRouter( router = APIRouter(
@@ -99,6 +110,27 @@ def _generate_unique_key(prefix: str, exists_fn) -> str:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_to_generate_{prefix.lower()}_key") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_to_generate_{prefix.lower()}_key")
def _serialize_api_client(item: ApiClient) -> ApiClientItem:
return ApiClientItem(
id=item.id,
client_key=item.client_key,
name=item.name,
status=item.status,
allowed_origins=item.allowed_origins or [],
allowed_ips=item.allowed_ips or [],
allowed_paths=item.allowed_paths or [],
rate_limit_per_min=item.rate_limit_per_min,
expires_at=item.expires_at,
last_used_at=item.last_used_at,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _generate_api_key() -> str:
return secrets.token_urlsafe(36)
def _sync_member_to_authentik( def _sync_member_to_authentik(
*, *,
authentik_sub: str | None, authentik_sub: str | None,
@@ -627,6 +659,106 @@ def set_member_permission_groups(
return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=unique_group_keys) return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=unique_group_keys)
@router.get("/api-clients")
def list_api_clients(
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
stmt = select(ApiClient)
count_stmt = select(ApiClient)
if keyword:
pattern = f"%{keyword}%"
filter_cond = (ApiClient.client_key.ilike(pattern)) | (ApiClient.name.ilike(pattern))
stmt = stmt.where(filter_cond)
count_stmt = count_stmt.where(filter_cond)
items = list(db.scalars(stmt.order_by(ApiClient.created_at.desc()).limit(limit).offset(offset)).all())
total = len(list(db.scalars(count_stmt)))
return {
"items": [_serialize_api_client(item).model_dump() for item in items],
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/api-clients", response_model=ApiClientCreateResponse)
def create_api_client(
payload: ApiClientCreateRequest,
db: Session = Depends(get_db),
) -> ApiClientCreateResponse:
status_value = payload.status.strip().lower()
if status_value not in {"active", "inactive"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status")
client_key = payload.client_key or _generate_unique_key(
"AC", lambda value: db.scalar(select(ApiClient).where(ApiClient.client_key == value)) is not None
)
exists = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key))
if exists:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="client_key_already_exists")
api_key = _generate_api_key()
row = ApiClient(
client_key=client_key,
name=payload.name,
status=status_value,
api_key_hash=hash_api_key(api_key),
allowed_origins=payload.allowed_origins,
allowed_ips=payload.allowed_ips,
allowed_paths=payload.allowed_paths,
rate_limit_per_min=payload.rate_limit_per_min,
expires_at=payload.expires_at,
)
db.add(row)
db.commit()
db.refresh(row)
return ApiClientCreateResponse(item=_serialize_api_client(row), api_key=api_key)
@router.patch("/api-clients/{client_key}", response_model=ApiClientItem)
def update_api_client(
client_key: str,
payload: ApiClientUpdateRequest,
db: Session = Depends(get_db),
) -> ApiClientItem:
row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key))
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found")
if payload.name is not None:
row.name = payload.name
if payload.status is not None:
next_status = payload.status.strip().lower()
if next_status not in {"active", "inactive"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status")
row.status = next_status
if payload.allowed_origins is not None:
row.allowed_origins = payload.allowed_origins
if payload.allowed_ips is not None:
row.allowed_ips = payload.allowed_ips
if payload.allowed_paths is not None:
row.allowed_paths = payload.allowed_paths
row.rate_limit_per_min = payload.rate_limit_per_min
row.expires_at = payload.expires_at
db.commit()
db.refresh(row)
return _serialize_api_client(row)
@router.post("/api-clients/{client_key}/rotate-key", response_model=ApiClientRotateKeyResponse)
def rotate_api_client_key(
client_key: str,
db: Session = Depends(get_db),
) -> ApiClientRotateKeyResponse:
row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key))
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found")
api_key = _generate_api_key()
row.api_key_hash = hash_api_key(api_key)
db.commit()
return ApiClientRotateKeyResponse(client_key=row.client_key, api_key=api_key)
@router.get("/permission-groups") @router.get("/permission-groups")
def list_permission_groups( def list_permission_groups(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -0,0 +1,49 @@
from datetime import datetime
from pydantic import BaseModel, Field
class ApiClientItem(BaseModel):
id: str
client_key: str
name: str
status: str
allowed_origins: list[str] = Field(default_factory=list)
allowed_ips: list[str] = Field(default_factory=list)
allowed_paths: list[str] = Field(default_factory=list)
rate_limit_per_min: int | None = None
expires_at: datetime | None = None
last_used_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ApiClientCreateRequest(BaseModel):
name: str
client_key: str | None = None
status: str = "active"
allowed_origins: list[str] = Field(default_factory=list)
allowed_ips: list[str] = Field(default_factory=list)
allowed_paths: list[str] = Field(default_factory=list)
rate_limit_per_min: int | None = None
expires_at: datetime | None = None
class ApiClientUpdateRequest(BaseModel):
name: str | None = None
status: str | None = None
allowed_origins: list[str] | None = None
allowed_ips: list[str] | None = None
allowed_paths: list[str] | None = None
rate_limit_per_min: int | None = None
expires_at: datetime | None = None
class ApiClientCreateResponse(BaseModel):
item: ApiClientItem
api_key: str
class ApiClientRotateKeyResponse(BaseModel):
client_key: str
api_key: str

View File

@@ -13,6 +13,10 @@ from app.models.api_client import ApiClient
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
def hash_api_key(plain_key: str) -> str:
return pwd_context.hash(plain_key)
def _verify_api_key(plain_key: str, stored_hash: str) -> bool: def _verify_api_key(plain_key: str, stored_hash: str) -> bool:
# Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production. # Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production.
if stored_hash.startswith("sha256:"): if stored_hash.startswith("sha256:"):

View File

@@ -61,7 +61,8 @@ const adminTabs = [
{ to: '/admin/companies', label: '公司' }, { to: '/admin/companies', label: '公司' },
{ to: '/admin/sites', label: '站台' }, { to: '/admin/sites', label: '站台' },
{ to: '/admin/members', label: '會員' }, { to: '/admin/members', label: '會員' },
{ to: '/admin/permission-groups', label: '群組' } { to: '/admin/permission-groups', label: '群組' },
{ to: '/admin/api-clients', label: 'API Clients' }
] ]
// 行內 NavTab 元件:避免另開檔案 // 行內 NavTab 元件:避免另開檔案

View File

@@ -0,0 +1,6 @@
import { adminHttp } from './http'
export const getApiClients = (params) => adminHttp.get('/admin/api-clients', { params })
export const createApiClient = (data) => adminHttp.post('/admin/api-clients', data)
export const updateApiClient = (clientKey, data) => adminHttp.patch(`/admin/api-clients/${clientKey}`, data)
export const rotateApiClientKey = (clientKey) => adminHttp.post(`/admin/api-clients/${clientKey}/rotate-key`)

View File

@@ -0,0 +1,282 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">API Clients</h2>
<div class="flex gap-2">
<el-button type="primary" @click="openCreate">新增 Client</el-button>
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
</div>
</div>
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
<el-table :data="items" stripe border class="w-full shadow-sm" v-loading="loading">
<template #empty><el-empty description="目前無 API Client" /></template>
<el-table-column prop="client_key" label="Client Key" min-width="180" />
<el-table-column prop="name" label="名稱" min-width="160" />
<el-table-column label="狀態" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Allowed Paths" min-width="220">
<template #default="{ row }">{{ (row.allowed_paths || []).join(', ') || '-' }}</template>
</el-table-column>
<el-table-column prop="last_used_at" label="最後使用" min-width="170" />
<el-table-column prop="expires_at" label="到期日" min-width="170" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">編輯</el-button>
<el-button size="small" type="warning" @click="handleRotate(row)">重置 Key</el-button>
<el-button size="small" :type="row.status === 'active' ? 'danger' : 'success'" @click="toggleStatus(row)">
{{ row.status === 'active' ? '停用' : '啟用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showCreateDialog" title="新增 API Client" width="700px" @close="resetCreate">
<el-form ref="createFormRef" :model="createForm" :rules="rules" label-width="130px">
<el-form-item label="名稱" prop="name"><el-input v-model="createForm.name" /></el-form-item>
<el-form-item label="Client Key">
<el-input v-model="createForm.client_key" placeholder="留空自動產生" />
</el-form-item>
<el-form-item label="Allowed Origins">
<el-input v-model="createAllowedOrigins" placeholder="逗號分隔,例如 https://erp.ose.tw" />
</el-form-item>
<el-form-item label="Allowed IPs">
<el-input v-model="createAllowedIps" placeholder="逗號分隔,例如 10.0.0.1,10.0.0.2" />
</el-form-item>
<el-form-item label="Allowed Paths">
<el-input v-model="createAllowedPaths" placeholder="逗號分隔,例如 /internal/" />
</el-form-item>
<el-form-item label="Rate Limit/min">
<el-input-number v-model="createForm.rate_limit_per_min" :min="0" :step="10" />
</el-form-item>
<el-form-item label="到期日">
<el-date-picker v-model="createExpiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss[Z]" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
</template>
</el-dialog>
<el-dialog v-model="showEditDialog" title="編輯 API Client" width="700px" @close="resetEdit">
<el-form :model="editForm" label-width="130px">
<el-form-item label="Client Key"><el-input :model-value="editForm.client_key" disabled /></el-form-item>
<el-form-item label="名稱"><el-input v-model="editForm.name" /></el-form-item>
<el-form-item label="狀態">
<el-select v-model="editForm.status">
<el-option label="active" value="active" />
<el-option label="inactive" value="inactive" />
</el-select>
</el-form-item>
<el-form-item label="Allowed Origins">
<el-input v-model="editAllowedOrigins" placeholder="逗號分隔" />
</el-form-item>
<el-form-item label="Allowed IPs">
<el-input v-model="editAllowedIps" placeholder="逗號分隔" />
</el-form-item>
<el-form-item label="Allowed Paths">
<el-input v-model="editAllowedPaths" placeholder="逗號分隔" />
</el-form-item>
<el-form-item label="Rate Limit/min">
<el-input-number v-model="editForm.rate_limit_per_min" :min="0" :step="10" />
</el-form-item>
<el-form-item label="到期日">
<el-date-picker v-model="editExpiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss[Z]" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">儲存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { createApiClient, getApiClients, rotateApiClientKey, updateApiClient } from '@/api/api-clients'
const items = ref([])
const loading = ref(false)
const error = ref(false)
const errorMsg = ref('')
const showCreateDialog = ref(false)
const showEditDialog = ref(false)
const creating = ref(false)
const saving = ref(false)
const createFormRef = ref()
const rules = {
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
}
const createForm = ref({
name: '',
client_key: '',
rate_limit_per_min: null
})
const createAllowedOrigins = ref('')
const createAllowedIps = ref('')
const createAllowedPaths = ref('/internal/')
const createExpiresAt = ref(null)
const editForm = ref({
client_key: '',
name: '',
status: 'active',
rate_limit_per_min: null
})
const editAllowedOrigins = ref('')
const editAllowedIps = ref('')
const editAllowedPaths = ref('')
const editExpiresAt = ref(null)
function toList(value) {
return String(value || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
}
function resetCreate() {
createForm.value = { name: '', client_key: '', rate_limit_per_min: null }
createAllowedOrigins.value = ''
createAllowedIps.value = ''
createAllowedPaths.value = '/internal/'
createExpiresAt.value = null
}
function resetEdit() {
editForm.value = { client_key: '', name: '', status: 'active', rate_limit_per_min: null }
editAllowedOrigins.value = ''
editAllowedIps.value = ''
editAllowedPaths.value = ''
editExpiresAt.value = null
}
function openCreate() {
resetCreate()
showCreateDialog.value = true
}
function openEdit(row) {
editForm.value = {
client_key: row.client_key,
name: row.name,
status: row.status,
rate_limit_per_min: row.rate_limit_per_min
}
editAllowedOrigins.value = (row.allowed_origins || []).join(', ')
editAllowedIps.value = (row.allowed_ips || []).join(', ')
editAllowedPaths.value = (row.allowed_paths || []).join(', ')
editExpiresAt.value = row.expires_at
showEditDialog.value = true
}
async function load() {
loading.value = true
error.value = false
try {
const res = await getApiClients()
items.value = res.data?.items || []
} catch (err) {
error.value = true
errorMsg.value = err.response?.data?.detail || '載入失敗'
} finally {
loading.value = false
}
}
async function handleCreate() {
const valid = await createFormRef.value.validate().catch(() => false)
if (!valid) return
creating.value = true
try {
const payload = {
name: createForm.value.name,
client_key: createForm.value.client_key || null,
allowed_origins: toList(createAllowedOrigins.value),
allowed_ips: toList(createAllowedIps.value),
allowed_paths: toList(createAllowedPaths.value),
rate_limit_per_min: createForm.value.rate_limit_per_min,
expires_at: createExpiresAt.value
}
const res = await createApiClient(payload)
const apiKey = res.data?.api_key || ''
if (apiKey) {
await navigator.clipboard.writeText(apiKey)
ElMessage.success(`建立成功API Key 已複製:${apiKey}`)
} else {
ElMessage.success('建立成功')
}
showCreateDialog.value = false
await load()
} catch (err) {
ElMessage.error(err.response?.data?.detail || '建立失敗')
} finally {
creating.value = false
}
}
async function handleSave() {
saving.value = true
try {
const payload = {
name: editForm.value.name,
status: editForm.value.status,
allowed_origins: toList(editAllowedOrigins.value),
allowed_ips: toList(editAllowedIps.value),
allowed_paths: toList(editAllowedPaths.value),
rate_limit_per_min: editForm.value.rate_limit_per_min,
expires_at: editExpiresAt.value
}
await updateApiClient(editForm.value.client_key, payload)
ElMessage.success('更新成功')
showEditDialog.value = false
await load()
} catch (err) {
ElMessage.error(err.response?.data?.detail || '更新失敗')
} finally {
saving.value = false
}
}
async function handleRotate(row) {
try {
await ElMessageBox.confirm(`確定要重置 ${row.client_key} 的 API Key 嗎?`, '重置確認', { type: 'warning' })
const res = await rotateApiClientKey(row.client_key)
const apiKey = res.data?.api_key || ''
if (apiKey) {
await navigator.clipboard.writeText(apiKey)
ElMessage.success(`重置成功API Key 已複製:${apiKey}`)
} else {
ElMessage.success('重置成功')
}
await load()
} catch (err) {
if (err === 'cancel') return
ElMessage.error(err.response?.data?.detail || '重置失敗')
}
}
async function toggleStatus(row) {
try {
const next = row.status === 'active' ? 'inactive' : 'active'
await updateApiClient(row.client_key, { status: next })
ElMessage.success(next === 'active' ? '已啟用' : '已停用')
await load()
} catch (err) {
ElMessage.error(err.response?.data?.detail || '更新狀態失敗')
}
}
onMounted(load)
</script>

View File

@@ -66,6 +66,12 @@ const routes = [
name: 'admin-permission-groups', name: 'admin-permission-groups',
component: () => import('@/pages/admin/PermissionGroupsPage.vue'), component: () => import('@/pages/admin/PermissionGroupsPage.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
},
{
path: '/admin/api-clients',
name: 'admin-api-clients',
component: () => import('@/pages/admin/ApiClientsPage.vue'),
meta: { requiresAuth: true }
} }
] ]