feat(admin): add managed Open API client keys
This commit is contained in:
@@ -5,6 +5,7 @@ import { api, setCsrf } from './api'
|
||||
|
||||
const menus = [
|
||||
{ id: 'tokens', label: '用户与 Token', icon: '👥' },
|
||||
{ id: 'clients', label: '服务密钥', icon: '🔐' },
|
||||
{ id: 'tools', label: '工具管理', icon: '🔧' },
|
||||
{ id: 'scopes', label: '作用域管理', icon: '🧩' },
|
||||
{ id: 'audit', label: '审计日志', icon: '📋' },
|
||||
@@ -20,6 +21,7 @@ const userPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
const selectedUser = ref(null)
|
||||
const permission = ref(null)
|
||||
const tokens = ref([])
|
||||
const openClients = ref([])
|
||||
const scopes = ref([])
|
||||
const tools = ref([])
|
||||
const audits = ref([])
|
||||
@@ -28,6 +30,9 @@ const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: ''
|
||||
const tokenDialog = ref(false)
|
||||
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
|
||||
const issuedTokens = ref([])
|
||||
const clientDialog = ref(false)
|
||||
const clientForm = reactive({ id: null, name: '', memo: '', scopeIds: [] })
|
||||
const issuedOpenKey = ref(null)
|
||||
const scopeDialog = ref(false)
|
||||
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
|
||||
const scopeToolsDialog = ref(false)
|
||||
@@ -41,6 +46,20 @@ const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
|
||||
;(groups[category] ||= []).push(tool)
|
||||
return groups
|
||||
}, {}))
|
||||
const issuedKeyCurl = computed(() => {
|
||||
if (!issuedOpenKey.value) return ''
|
||||
const payload = JSON.stringify({
|
||||
scopeCode: 'sync', principalType: 'service', serviceName: issuedOpenKey.value.client.name,
|
||||
})
|
||||
return [
|
||||
"curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \\",
|
||||
` -H 'X-Open-Key: ${issuedOpenKey.value.plaintext}' \\`,
|
||||
" -H 'Content-Type: application/json' \\",
|
||||
` -d ${shellQuote(payload)}`,
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'` }
|
||||
|
||||
function qs(values) {
|
||||
const params = new URLSearchParams()
|
||||
@@ -76,6 +95,7 @@ async function logout() {
|
||||
async function switchMenu(id) {
|
||||
current.value = id
|
||||
if (id === 'tokens') await loadUsers()
|
||||
if (id === 'clients') await loadOpenClients()
|
||||
if (id === 'tools') await loadTools()
|
||||
if (id === 'scopes') await loadScopes()
|
||||
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
|
||||
@@ -146,6 +166,54 @@ async function deleteToken(token) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function loadOpenClients() {
|
||||
;[openClients.value, scopes.value] = await Promise.all([api('/open-clients'), api('/scopes')])
|
||||
}
|
||||
|
||||
function openClient(client = null) {
|
||||
Object.assign(clientForm, client ? {
|
||||
id: client.id, name: client.name, memo: client.memo || '', scopeIds: [...client.scopeIds],
|
||||
} : { id: null, name: '', memo: '', scopeIds: [] })
|
||||
clientDialog.value = true
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
if (!clientForm.name.trim()) return ElMessage.warning('请填写调用方名称')
|
||||
if (!clientForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
|
||||
await run(async () => {
|
||||
const body = JSON.stringify({ name: clientForm.name, memo: clientForm.memo, scopeIds: clientForm.scopeIds })
|
||||
if (clientForm.id) await api(`/open-clients/${clientForm.id}`, { method: 'PUT', body })
|
||||
else issuedOpenKey.value = await api('/open-clients', { method: 'POST', body })
|
||||
clientDialog.value = false
|
||||
await loadOpenClients()
|
||||
ElMessage.success(clientForm.id ? '服务密钥配置已保存' : '服务密钥已创建')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function setClientStatus(client) {
|
||||
if (client.status === 1) {
|
||||
try { await ElMessageBox.confirm(`吊销后 ${client.name} 将立即无法用此密钥换取 Token,确认继续?`, '吊销服务密钥', { type: 'warning' }) }
|
||||
catch { return }
|
||||
}
|
||||
await run(async () => {
|
||||
await api(`/open-clients/${client.id}/status`, {
|
||||
method: 'PATCH', body: JSON.stringify({ status: client.status === 1 ? 0 : 1 }),
|
||||
})
|
||||
await loadOpenClients()
|
||||
ElMessage.success(client.status === 1 ? '服务密钥已吊销' : '服务密钥已启用')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function rotateClient(client) {
|
||||
try { await ElMessageBox.confirm(`轮换后 ${client.name} 的旧密钥立即失效,确认继续?`, '轮换服务密钥', { type: 'warning' }) }
|
||||
catch { return }
|
||||
await run(async () => {
|
||||
issuedOpenKey.value = await api(`/open-clients/${client.id}/rotate`, { method: 'POST' })
|
||||
await loadOpenClients()
|
||||
ElMessage.success('服务密钥已轮换')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
tools.value = await api(`/tools${qs(toolFilter)}`)
|
||||
}
|
||||
@@ -212,6 +280,7 @@ async function copyText(value) { await navigator.clipboard.writeText(value); ElM
|
||||
async function copyAllTokens() {
|
||||
await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n'))
|
||||
}
|
||||
function clearIssuedOpenKey() { issuedOpenKey.value = null }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -242,6 +311,7 @@ onMounted(async () => {
|
||||
<div class="logo"><span>趣</span> 智能体服务后台</div>
|
||||
<nav aria-label="主导航">
|
||||
<button :class="['menu-item', { active: current === 'tokens' }]" data-testid="admin-menu-tokens" @click="switchMenu('tokens')">👥 用户与 Token</button>
|
||||
<button :class="['menu-item', { active: current === 'clients' }]" data-testid="admin-menu-clients" @click="switchMenu('clients')">🔐 服务密钥</button>
|
||||
<button :class="['menu-item', { active: current === 'tools' }]" data-testid="admin-menu-tools" @click="switchMenu('tools')">🔧 工具管理</button>
|
||||
<button :class="['menu-item', { active: current === 'scopes' }]" data-testid="admin-menu-scopes" @click="switchMenu('scopes')">🧩 作用域管理</button>
|
||||
<button :class="['menu-item', { active: current === 'audit' }]" data-testid="admin-menu-audit" @click="switchMenu('audit')">📋 审计日志</button>
|
||||
@@ -311,6 +381,31 @@ onMounted(async () => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="current === 'clients'">
|
||||
<article class="card">
|
||||
<div class="card-title">
|
||||
<span>服务密钥调用方</span>
|
||||
<el-button type="primary" data-testid="admin-btn-new-client" @click="openClient()">+ 创建服务密钥</el-button>
|
||||
</div>
|
||||
<el-alert title="密钥明文只在创建或轮换成功后展示一次;已通过环境变量配置的存量密钥继续有效,但不在本页展示。" type="info" :closable="false" show-icon />
|
||||
<el-table :data="openClients" class="client-table" data-testid="admin-table-clients" empty-text="暂无服务密钥">
|
||||
<el-table-column prop="name" label="调用方" min-width="140" />
|
||||
<el-table-column label="密钥前缀" min-width="145"><template #default="{ row }"><code>{{ row.keyPrefix }}…</code></template></el-table-column>
|
||||
<el-table-column prop="memo" label="用途备注" min-width="180" />
|
||||
<el-table-column label="允许作用域" min-width="200"><template #default="{ row }"><el-tag v-for="(name, index) in row.scopeNames" :key="row.scopeIds[index]" size="small" effect="plain">{{ name }}({{ row.scopeCodes[index] }})</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '正常' : '已吊销' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="最后使用" min-width="155"><template #default="{ row }">{{ date(row.lastUsedTime) }}</template></el-table-column>
|
||||
<el-table-column label="更新时间" min-width="155"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="205" fixed="right"><template #default="{ row }">
|
||||
<el-button link type="primary" :data-testid="`admin-client-edit-${row.id}`" @click="openClient(row)">编辑</el-button>
|
||||
<el-button link type="warning" :data-testid="`admin-client-rotate-${row.id}`" @click="rotateClient(row)">轮换</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" :data-testid="`admin-client-status-${row.id}`" @click="setClientStatus(row)">{{ row.status === 1 ? '吊销' : '启用' }}</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
<p class="hint">服务密钥用于调用 <code>/api/open/token/ensure</code> 换取业务 Token;作用域决定该密钥能为哪些数据域签发或获取 Token。</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<template v-else-if="current === 'tools'">
|
||||
<article class="card">
|
||||
<div class="toolbar">
|
||||
@@ -416,6 +511,25 @@ onMounted(async () => {
|
||||
<template #footer><el-button data-testid="admin-scope-cancel" @click="scopeDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-save" @click="saveScope">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="clientDialog" :title="clientForm.id ? '编辑服务密钥' : '创建服务密钥'" width="560px" destroy-on-close>
|
||||
<el-form label-width="92px">
|
||||
<el-form-item label="调用方名称"><el-input v-model="clientForm.name" maxlength="64" placeholder="如 crm" aria-label="服务密钥调用方名称" data-testid="admin-client-name" /></el-form-item>
|
||||
<el-form-item label="用途备注"><el-input v-model="clientForm.memo" type="textarea" maxlength="255" placeholder="如:CRM 合同增量同步" aria-label="服务密钥用途备注" data-testid="admin-client-memo" /></el-form-item>
|
||||
<el-form-item label="允许作用域">
|
||||
<el-checkbox-group v-model="clientForm.scopeIds" data-testid="admin-client-scopes">
|
||||
<el-checkbox
|
||||
v-for="scope in scopes"
|
||||
:key="scope.id"
|
||||
:value="scope.id"
|
||||
:disabled="scope.status !== 1 && !clientForm.scopeIds.includes(scope.id)"
|
||||
>{{ scope.name }}({{ scope.code }})<template v-if="scope.status !== 1"> · 已停用</template></el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<div class="hint" style="margin:4px 0 0">CRM 后台增量同步请选择 sync;其他调用方按实际用途授权。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button data-testid="admin-client-cancel" @click="clientDialog = false">取消</el-button><el-button type="primary" data-testid="admin-client-save" @click="saveClient">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="680px" destroy-on-close>
|
||||
<el-checkbox-group v-model="selectedToolIds" class="tool-groups" data-testid="admin-scope-tool-options">
|
||||
<section v-for="(items, category) in groupedTools" :key="category" class="tool-group"><h3>{{ category }}</h3><el-checkbox v-for="tool in items" :key="tool.id" :value="tool.id" :data-testid="`admin-scope-tool-${tool.id}`"><code>{{ tool.name }}</code><small>{{ tool.description }}</small></el-checkbox></section>
|
||||
@@ -432,4 +546,18 @@ onMounted(async () => {
|
||||
</div>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-all-tokens" @click="copyAllTokens">全部复制</el-button><el-button data-testid="admin-close-token" @click="issuedTokens = []">我已保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!issuedOpenKey" title="服务密钥已生成" width="720px" :close-on-click-modal="false" @close="clearIssuedOpenKey">
|
||||
<el-alert title="明文只展示这一次,请立即复制并交给对应系统安全保存。关闭后后台无法找回,只能轮换。" type="warning" :closable="false" show-icon />
|
||||
<template v-if="issuedOpenKey">
|
||||
<div class="issued-row">
|
||||
<code class="token-secret" data-testid="admin-issued-client-key">{{ issuedOpenKey.plaintext }}</code>
|
||||
<el-button link type="primary" data-testid="admin-copy-client-key" @click="copyText(issuedOpenKey.plaintext)">复制密钥</el-button>
|
||||
</div>
|
||||
<p class="field-label key-example-label">CRM 换取服务 Token 示例</p>
|
||||
<pre class="key-example" data-testid="admin-client-curl">{{ issuedKeyCurl }}</pre>
|
||||
<p class="hint">如果绑定的不是 sync 作用域,请按实际作用域修改请求中的 scopeCode 和主体参数。</p>
|
||||
</template>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-client-curl" @click="copyText(issuedKeyCurl)">复制调用示例</el-button><el-button data-testid="admin-close-client-key" @click="clearIssuedOpenKey">我已保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -62,4 +62,7 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
|
||||
.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
|
||||
.client-table { margin-top: 16px; }
|
||||
.key-example-label { margin: 18px 0 7px; }
|
||||
.key-example { margin: 0; padding: 14px; overflow: auto; color: #e6edf3; background: #1f2328; border-radius: 5px; font: 12px/1.6 Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } }
|
||||
|
||||
Reference in New Issue
Block a user