feat(admin): add api client management UI and backend CRUD/rotate endpoints
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
49
backend/app/schemas/api_clients.py
Normal file
49
backend/app/schemas/api_clients.py
Normal 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
|
||||||
@@ -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:"):
|
||||||
|
|||||||
@@ -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 元件:避免另開檔案
|
||||||
|
|||||||
6
frontend/src/api/api-clients.js
Normal file
6
frontend/src/api/api-clients.js
Normal 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`)
|
||||||
282
frontend/src/pages/admin/ApiClientsPage.vue
Normal file
282
frontend/src/pages/admin/ApiClientsPage.vue
Normal 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>
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user