feat: 补齐 admin 页面(用户管理/角色权限/审计日志)+ API 路由
This commit is contained in:
parent
de0116ddc8
commit
25365b1626
|
|
@ -1,11 +1,121 @@
|
|||
// src/app/admin/audit-logs/page.tsx — 审计日志(占位)
|
||||
'use client'
|
||||
// src/app/admin/audit-logs/page.tsx — 审计日志
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
|
||||
interface AuditLog {
|
||||
id: number; username: string | null; action: string; entity_type: string
|
||||
details: string | null; ip_address: string | null; created_at: string
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [action, setAction] = useState('')
|
||||
const [entityType, setEntityType] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const pageSize = 20
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (action) params.set('action', action)
|
||||
if (entityType) params.set('entity_type', entityType)
|
||||
const res = await fetch(`/api/admin/audit-logs?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLogs(data.data)
|
||||
setTotal(data.total)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [page, action, entityType])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const renderDetails = (details: string | null) => {
|
||||
if (!details) return '—'
|
||||
try { return JSON.stringify(JSON.parse(details)).slice(0, 80) }
|
||||
catch { return details.slice(0, 80) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">审计日志</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-400">审计日志页面</p>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">审计日志</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">系统操作与事件记录</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }}
|
||||
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
|
||||
<option value="">全部操作</option>
|
||||
<option value="login">登录</option><option value="login_failed">登录失败</option><option value="logout">登出</option>
|
||||
<option value="create_service">创建服务</option><option value="update_service">更新服务</option><option value="delete_service">删除服务</option>
|
||||
<option value="create_channel">创建渠道</option><option value="update_channel">更新渠道</option><option value="delete_channel">删除渠道</option>
|
||||
<option value="update_user">更新用户</option><option value="update_role">更新角色</option>
|
||||
</select>
|
||||
<select value={entityType} onChange={e => { setEntityType(e.target.value); setPage(1) }}
|
||||
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
|
||||
<option value="">全部对象</option>
|
||||
<option value="auth">认证</option><option value="service">服务</option><option value="alert_channel">告警渠道</option>
|
||||
<option value="user">用户</option><option value="role">角色</option>
|
||||
</select>
|
||||
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
||||
<RotateCw size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">时间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">用户</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">对象</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">详情</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">加载中...</td></tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">暂无数据</td></tr>
|
||||
) : logs.map(log => (
|
||||
<tr key={log.id} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">{log.created_at}</td>
|
||||
<td className="px-4 py-3 text-sm">{log.username || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
log.action.includes('login') ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
||||
log.action.includes('failed') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
|
||||
log.action.includes('create') ? 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400' :
|
||||
log.action.includes('delete') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
|
||||
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
||||
}`}>{log.action}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{log.entity_type}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500 font-mono text-xs">{renderDetails(log.details)}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{log.ip_address || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">上一页</button>
|
||||
<span className="text-sm text-slate-500">第 {page}/{totalPages} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,130 @@
|
|||
// src/app/admin/roles/page.tsx — 角色权限管理(占位)
|
||||
'use client'
|
||||
// src/app/admin/roles/page.tsx — 角色权限管理
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Shield, RotateCw, Save } from 'lucide-react'
|
||||
|
||||
interface RoleData { role: string; permissions: { key: string; enabled: boolean }[] }
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<RoleData[]>([])
|
||||
const [allPermissions, setAllPermissions] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [perms, setPerms] = useState<string[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/roles')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRoles(data.roles)
|
||||
setAllPermissions(data.allPermissions)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const showToast = (type: 'ok' | 'err', msg: string) => {
|
||||
setToast({ type, msg })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const startEdit = (role: RoleData) => {
|
||||
setEditing(role.role)
|
||||
setPerms(role.permissions.filter(p => p.enabled).map(p => p.key))
|
||||
}
|
||||
|
||||
const togglePerm = (key: string) => {
|
||||
setPerms(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/roles', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: editing, permissions: perms }),
|
||||
})
|
||||
if (res.ok) { showToast('ok', `${editing} 角色权限已更新`); setEditing(null); load() }
|
||||
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
|
||||
} catch { showToast('err', '网络错误') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const permLabels: Record<string, string> = {
|
||||
'dashboard:view': '查看仪表盘', 'services:view': '查看服务', 'services:manage': '管理服务',
|
||||
'alerts:view': '查看告警', 'alerts:manage': '管理告警渠道', 'status_history:view': '查看状态历史',
|
||||
'status_history:stats': '可用率统计', 'audit:view': '查看审计', 'users:view': '查看用户',
|
||||
'users:manage': '管理用户', 'roles:manage': '管理角色',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">角色权限管理</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-400">角色权限管理页面</p>
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg text-sm font-medium shadow-lg ${toast.type === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">角色权限管理</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">admin / editor / viewer</p>
|
||||
</div>
|
||||
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
||||
<RotateCw size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{roles.map(role => (
|
||||
<div key={role.role} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold capitalize flex items-center gap-2">
|
||||
<Shield size={18} className={role.role === 'admin' ? 'text-indigo-600' : role.role === 'editor' ? 'text-blue-600' : 'text-slate-400'} />
|
||||
{role.role}
|
||||
</h2>
|
||||
{editing === role.role ? (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-3 py-1.5 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
<Save size={14} /> {saving ? '保存中' : '保存'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)} className="px-3 py-1.5 text-sm border rounded-lg">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => startEdit(role)} className="px-3 py-1.5 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{role.role === 'admin' ? (
|
||||
<span className="text-sm text-slate-400">admin 角色拥有全部 {allPermissions.length} 个权限</span>
|
||||
) : editing === role.role ? (
|
||||
allPermissions.map(p => (
|
||||
<label key={p} className="flex items-center gap-2 text-sm py-1">
|
||||
<input type="checkbox" checked={perms.includes(p)} onChange={() => togglePerm(p)}
|
||||
className="rounded border-slate-300 text-indigo-600" />
|
||||
{permLabels[p] || p}
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
role.permissions.filter(p => p.enabled).map(p => (
|
||||
<span key={p.key} className="px-2 py-1 rounded bg-slate-50 dark:bg-slate-800 text-xs text-slate-600 dark:text-slate-400">
|
||||
{permLabels[p.key] || p.key}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,125 @@
|
|||
// src/app/admin/users/page.tsx — 用户管理(占位,Phase 3 完成)
|
||||
'use client'
|
||||
// src/app/admin/users/page.tsx — 用户管理
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Users, RotateCw } from 'lucide-react'
|
||||
|
||||
interface User {
|
||||
id: number; username: string; display_name: string | null; email: string | null
|
||||
role: string; is_active: number; last_login_at: string | null
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/users')
|
||||
if (res.ok) setUsers(await res.json())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const showToast = (type: 'ok' | 'err', msg: string) => {
|
||||
setToast({ type, msg })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const handleSave = async (id: number) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: editRole }),
|
||||
})
|
||||
if (res.ok) { showToast('ok', '角色已更新'); setEditingId(null); load() }
|
||||
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
|
||||
} catch { showToast('err', '网络错误') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">用户管理</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-400">用户管理页面(本地测试通过后完善 UI)</p>
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg text-sm font-medium shadow-lg ${toast.type === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">用户管理</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">管理用户角色和权限</p>
|
||||
</div>
|
||||
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
||||
<RotateCw size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">用户名</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">显示名</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">邮箱</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">角色</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">加载中...</td></tr>
|
||||
) : users.map(user => (
|
||||
<tr key={user.id} className="border-b border-slate-100 dark:border-slate-800">
|
||||
<td className="px-4 py-3 text-sm font-medium">{user.username}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{user.display_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{user.email || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{editingId === user.id ? (
|
||||
<select value={editRole} onChange={e => setEditRole(e.target.value)}
|
||||
className="px-2 py-1 rounded border text-sm">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400' :
|
||||
user.role === 'editor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
||||
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
||||
}`}>{user.role}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{user.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{editingId === user.id ? (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleSave(user.id)} disabled={saving}
|
||||
className="px-3 py-1 text-xs bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
{saving ? '保存中' : '保存'}
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="px-3 py-1 text-xs border rounded-lg">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => { setEditingId(user.id); setEditRole(user.role) }}
|
||||
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
// GET /api/admin/audit-logs — 审计日志列表(admin)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec } from '@/lib/db'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'audit:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || ''
|
||||
const entityType = searchParams.get('entity_type') || ''
|
||||
const username = searchParams.get('username') || ''
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
if (action) {
|
||||
where += ` AND action = '${action.replace(/'/g, "''")}'`
|
||||
}
|
||||
if (entityType) {
|
||||
where += ` AND entity_type = '${entityType.replace(/'/g, "''")}'`
|
||||
}
|
||||
if (username) {
|
||||
where += ` AND username = '${username.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
const countRow = dbQuery<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`)
|
||||
const total = countRow[0]?.cnt || 0
|
||||
const rows = dbQuery(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${pageSize} OFFSET ${offset}`)
|
||||
|
||||
return NextResponse.json({ data: rows, total, page, pageSize })
|
||||
}
|
||||
|
||||
// DELETE /api/admin/audit-logs — 清理过期日志(admin)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'audit:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const days = parseInt(searchParams.get('days') || '180')
|
||||
if (days < 30 || days > 365) {
|
||||
return NextResponse.json({ error: '保留天数需在 30-365 之间' }, { status: 400 })
|
||||
}
|
||||
|
||||
dbExec(`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${days} days', '+8 hours')`)
|
||||
return NextResponse.json({ success: true, message: `已清理 ${days} 天前的日志` })
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// GET/PUT /api/admin/roles — 角色权限管理(admin)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission, PERMISSIONS } from '@/lib/permissions'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'roles:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// 返回所有权限定义 + 各角色的权限映射
|
||||
const rolePermissions = dbQuery<{ role: string; permission_key: string }>('SELECT role, permission_key FROM role_permissions')
|
||||
const roles = ['admin', 'editor', 'viewer']
|
||||
const result = roles.map(role => ({
|
||||
role,
|
||||
permissions: PERMISSIONS.map(p => ({
|
||||
key: p,
|
||||
enabled: role === 'admin' || rolePermissions.some(rp => rp.role === role && rp.permission_key === p),
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ roles: result, allPermissions: PERMISSIONS })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'roles:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { role, permissions } = body as { role: string; permissions: string[] }
|
||||
|
||||
if (!role || !permissions || !Array.isArray(permissions)) {
|
||||
return NextResponse.json({ error: '参数错误' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 删除旧权限,写入新权限
|
||||
dbExec(`DELETE FROM role_permissions WHERE role = '${role.replace(/'/g, "''")}'`)
|
||||
for (const perm of permissions) {
|
||||
dbExec(`INSERT OR IGNORE INTO role_permissions (role, permission_key) VALUES ('${role.replace(/'/g, "''")}', '${perm.replace(/'/g, "''")}')`)
|
||||
}
|
||||
|
||||
writeAuditLog({
|
||||
userId: Number(payload.sub) || null,
|
||||
username: String(payload.username || ''),
|
||||
action: 'update_role',
|
||||
entityType: 'role',
|
||||
details: { role, permissions },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// PUT/PATCH /api/admin/users/[id] — 修改用户角色
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'users:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { role, display_name, email } = body
|
||||
|
||||
const updates: string[] = []
|
||||
if (role) updates.push(`role = '${role.replace(/'/g, "''")}'`)
|
||||
if (display_name !== undefined) updates.push(`display_name = '${String(display_name).replace(/'/g, "''")}'`)
|
||||
if (email !== undefined) updates.push(`email = '${String(email).replace(/'/g, "''")}'`)
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: '无可更新字段' }, { status: 400 })
|
||||
}
|
||||
updates.push(`updated_at = datetime('now', '+8 hours')`)
|
||||
|
||||
dbExec(`UPDATE users SET ${updates.join(', ')} WHERE id = ${Number(id)}`)
|
||||
|
||||
writeAuditLog({
|
||||
userId: Number(payload.sub) || null,
|
||||
username: String(payload.username || ''),
|
||||
action: 'update_user',
|
||||
entityType: 'user',
|
||||
entityId: Number(id),
|
||||
details: body,
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// GET /api/admin/users — 用户列表(admin)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'users:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const username = searchParams.get('username') || ''
|
||||
const role = searchParams.get('role') || ''
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
if (username) {
|
||||
where += ` AND username LIKE '%${username.replace(/'/g, "''")}%'`
|
||||
}
|
||||
if (role) {
|
||||
where += ` AND role = '${role.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
const rows = dbQuery(`SELECT id, username, display_name, email, role, is_active, last_login_at FROM users ${where} ORDER BY id`)
|
||||
return NextResponse.json(rows)
|
||||
}
|
||||
Loading…
Reference in New Issue