Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor(saas): 重构认证中间件与限流策略
- 登录限流调整为5次/分钟/IP
- 注册限流调整为3次/小时/IP
- GET请求不计入限流
fix(saas): 修复调度器时间戳处理
- 使用NOW()替代文本时间戳
- 兼容TEXT和TIMESTAMPTZ列类型
feat(saas): 实现环境变量插值
- 支持${ENV_VAR}语法解析
- 数据库密码支持环境变量注入
chore: 新增前端管理界面
- 基于React+Ant Design Pro
- 包含路由守卫/错误边界
- 对接58个API端点
docs: 更新安全加固文档
- 新增密钥管理规范
- 记录P0安全项审计结果
- 补充TLS终止说明
test: 完善配置解析单元测试
- 新增环境变量插值测试用例
166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
// ============================================================
|
||
// API 密钥管理
|
||
// ============================================================
|
||
|
||
import { useState } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd'
|
||
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
|
||
import type { ProColumns } from '@ant-design/pro-components'
|
||
import { ProTable } from '@ant-design/pro-components'
|
||
import { apiKeyService } from '@/services/api-keys'
|
||
import type { TokenInfo } from '@/types'
|
||
|
||
const { Text } = Typography
|
||
|
||
export default function ApiKeys() {
|
||
const queryClient = useQueryClient()
|
||
const [form] = Form.useForm()
|
||
const [modalOpen, setModalOpen] = useState(false)
|
||
const [newToken, setNewToken] = useState<string | null>(null)
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['api-keys'],
|
||
queryFn: ({ signal }) => apiKeyService.list(signal),
|
||
})
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) =>
|
||
apiKeyService.create(data),
|
||
onSuccess: (result: TokenInfo) => {
|
||
message.success('创建成功')
|
||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||
if (result.token) {
|
||
setNewToken(result.token)
|
||
}
|
||
setModalOpen(false)
|
||
form.resetFields()
|
||
},
|
||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||
})
|
||
|
||
const revokeMutation = useMutation({
|
||
mutationFn: (id: string) => apiKeyService.revoke(id),
|
||
onSuccess: () => {
|
||
message.success('已撤销')
|
||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||
},
|
||
onError: (err: Error) => message.error(err.message || '撤销失败'),
|
||
})
|
||
|
||
const columns: ProColumns<TokenInfo>[] = [
|
||
{ title: '名称', dataIndex: 'name', width: 160 },
|
||
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
|
||
{
|
||
title: '权限',
|
||
dataIndex: 'permissions',
|
||
width: 200,
|
||
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
|
||
},
|
||
{
|
||
title: '过期时间',
|
||
dataIndex: 'expires_at',
|
||
width: 180,
|
||
render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期',
|
||
},
|
||
{
|
||
title: '最后使用',
|
||
dataIndex: 'last_used_at',
|
||
width: 180,
|
||
render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-',
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
dataIndex: 'created_at',
|
||
width: 180,
|
||
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 100,
|
||
render: (_, record) => (
|
||
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
|
||
<Button size="small" danger>撤销</Button>
|
||
</Popconfirm>
|
||
),
|
||
},
|
||
]
|
||
|
||
const handleCreate = async () => {
|
||
const values = await form.validateFields()
|
||
createMutation.mutate(values)
|
||
}
|
||
|
||
const copyToken = () => {
|
||
if (newToken) {
|
||
navigator.clipboard.writeText(newToken)
|
||
message.success('已复制到剪贴板')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<ProTable<TokenInfo>
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
loading={isLoading}
|
||
rowKey="id"
|
||
search={false}
|
||
toolBarRender={() => [
|
||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
|
||
创建密钥
|
||
</Button>,
|
||
]}
|
||
pagination={{
|
||
total: data?.total ?? 0,
|
||
pageSize: data?.page_size ?? 20,
|
||
current: data?.page ?? 1,
|
||
showSizeChanger: false,
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title="创建 API 密钥"
|
||
open={modalOpen}
|
||
onOk={handleCreate}
|
||
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||
confirmLoading={createMutation.isPending}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||
<Input placeholder="给密钥起个名字" />
|
||
</Form.Item>
|
||
<Form.Item name="expires_days" label="有效期 (天)">
|
||
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
|
||
<Select mode="multiple" placeholder="选择权限" options={[
|
||
{ value: 'relay:use', label: '中转使用' },
|
||
{ value: 'model:read', label: '模型读取' },
|
||
{ value: 'config:read', label: '配置读取' },
|
||
]} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="密钥创建成功"
|
||
open={!!newToken}
|
||
onOk={() => setNewToken(null)}
|
||
onCancel={() => setNewToken(null)}
|
||
>
|
||
<p>请立即保存此密钥,关闭后将无法再次查看:</p>
|
||
<Input.TextArea
|
||
value={newToken || ''}
|
||
rows={3}
|
||
readOnly
|
||
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
|
||
/>
|
||
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
|
||
复制密钥
|
||
</Button>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|