183 lines
7.4 KiB
TypeScript
183 lines
7.4 KiB
TypeScript
'use client'
|
||
import { useState, useEffect } from 'react'
|
||
import { Card, Button, Table, Badge, Modal, Input } from '@/components/ui'
|
||
import { Plus, Trash2, Copy, Check, Key } from 'lucide-react'
|
||
|
||
interface ApiKey {
|
||
id: number
|
||
name: string
|
||
permissions: string
|
||
last_used_at: string | null
|
||
expires_at: string | null
|
||
is_active: number
|
||
created_at: string
|
||
}
|
||
|
||
export default function ApiKeysPage() {
|
||
const [keys, setKeys] = useState<ApiKey[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [editTarget, setEditTarget] = useState<ApiKey | null>(null)
|
||
const [deleteTarget, setDeleteTarget] = useState<ApiKey | null>(null)
|
||
const [name, setName] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [newKey, setNewKey] = useState<string | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
const fetchKeys = () => {
|
||
fetch('/api/api-keys')
|
||
.then(r => r.json())
|
||
.then(d => { if (d.keys) setKeys(d.keys) })
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false))
|
||
}
|
||
|
||
useEffect(() => { fetchKeys() }, [])
|
||
|
||
const handleCreate = async () => {
|
||
if (!name.trim()) return
|
||
setSaving(true)
|
||
setError('')
|
||
try {
|
||
const res = await fetch('/api/api-keys', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name.trim(), permissions: ['tickets:read'] }),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) { setError(data.error || '创建失败'); return }
|
||
setNewKey(data.key)
|
||
setCreateOpen(false)
|
||
setName('')
|
||
fetchKeys()
|
||
} catch {
|
||
setError('创建失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async () => {
|
||
if (!deleteTarget) return
|
||
const res = await fetch(`/api/api-keys/${deleteTarget.id}`, { method: 'DELETE' })
|
||
if (res.ok) {
|
||
setDeleteTarget(null)
|
||
fetchKeys()
|
||
}
|
||
}
|
||
|
||
const handleToggleActive = async (k: ApiKey) => {
|
||
await fetch(`/api/api-keys/${k.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: k.name, permissions: JSON.parse(k.permissions), is_active: !k.is_active }),
|
||
})
|
||
fetchKeys()
|
||
}
|
||
|
||
const copyKey = (key: string) => {
|
||
navigator.clipboard.writeText(key)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">API Key 管理</h1>
|
||
<p className="text-slate-500 dark:text-slate-400 mt-1">用于第三方系统调用工单系统 API</p>
|
||
</div>
|
||
<Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}>
|
||
<Plus size={16} className="mr-1" />创建 Key
|
||
</Button>
|
||
</div>
|
||
|
||
{newKey && (
|
||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl">
|
||
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-2">
|
||
API Key 已创建(仅显示一次,请妥善保存)
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 p-3 bg-white dark:bg-slate-800 rounded-lg border border-emerald-200 dark:border-emerald-700 text-sm font-mono break-all text-slate-800 dark:text-slate-200">
|
||
{newKey}
|
||
</code>
|
||
<Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}>
|
||
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||
) : keys.length === 0 ? (
|
||
<Card className="p-12 text-center">
|
||
<Key size={40} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
|
||
<p className="text-slate-500 dark:text-slate-400">暂无 API Key</p>
|
||
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">点击右上角「创建 Key」生成新的密钥</p>
|
||
</Card>
|
||
) : (
|
||
<Table headers={['名称', '权限', '状态', '最后使用', '过期时间', '创建时间', '操作']}>
|
||
{keys.map(k => {
|
||
const perms: string[] = JSON.parse(k.permissions)
|
||
return (
|
||
<tr key={k.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{k.name}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-1">
|
||
{perms.map(p => <Badge key={p} variant="default">{p}</Badge>)}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<button onClick={() => handleToggleActive(k)}>
|
||
<Badge variant={k.is_active ? 'success' : 'danger'}>{k.is_active ? '启用' : '禁用'}</Badge>
|
||
</button>
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{k.last_used_at ? new Date(k.last_used_at).toLocaleString('zh-CN') : '从未使用'}</td>
|
||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{k.expires_at ? new Date(k.expires_at).toLocaleString('zh-CN') : '永不过期'}</td>
|
||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{new Date(k.created_at).toLocaleString('zh-CN')}</td>
|
||
<td className="px-4 py-3">
|
||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(k)}>
|
||
<Trash2 size={14} className="text-red-500" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</Table>
|
||
)}
|
||
|
||
<Modal open={createOpen} onClose={() => { setCreateOpen(false); setName(''); setError('') }} title="创建 API Key">
|
||
<div className="space-y-4">
|
||
<Input
|
||
label="名称"
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
placeholder="例如:资产管理系统调用"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
默认权限:<Badge variant="default">tickets:read</Badge>(仅读取工单数据)
|
||
</p>
|
||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||
<div className="flex gap-3">
|
||
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
||
<Button variant="secondary" onClick={() => { setCreateOpen(false); setError('') }}>取消</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法再访问此系统。
|
||
</p>
|
||
<div className="flex justify-end gap-3 mt-4">
|
||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|