diff --git a/src/app/admin/audit-logs/page.tsx b/src/app/admin/audit-logs/page.tsx index 44f440c..de000ca 100644 --- a/src/app/admin/audit-logs/page.tsx +++ b/src/app/admin/audit-logs/page.tsx @@ -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([]) + 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 (
-

审计日志

-
-

审计日志页面

+
+

审计日志

+

系统操作与事件记录

+ +
+ + + +
+ +
+ + + + + + + + + + + + + {loading ? ( + + ) : logs.length === 0 ? ( + + ) : logs.map(log => ( + + + + + + + + + ))} + +
时间用户操作对象详情IP
加载中...
暂无数据
{log.created_at}{log.username || '—'} + {log.action} + {log.entity_type}{renderDetails(log.details)}{log.ip_address || '—'}
+
+ + {totalPages > 1 && ( +
+ + 第 {page}/{totalPages} 页 (共 {total} 条) + +
+ )}
) } diff --git a/src/app/admin/roles/page.tsx b/src/app/admin/roles/page.tsx index 7039913..f068e25 100644 --- a/src/app/admin/roles/page.tsx +++ b/src/app/admin/roles/page.tsx @@ -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([]) + const [allPermissions, setAllPermissions] = useState([]) + const [loading, setLoading] = useState(true) + const [editing, setEditing] = useState(null) + const [perms, setPerms] = 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/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 = { + '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 (
-

角色权限管理

-
-

角色权限管理页面

+ {toast && ( +
+ {toast.msg} +
+ )} + +
+
+

角色权限管理

+

admin / editor / viewer

+
+
+ + {loading ? ( +
加载中...
+ ) : ( +
+ {roles.map(role => ( +
+
+

+ + {role.role} +

+ {editing === role.role ? ( +
+ + +
+ ) : ( + + )} +
+
+ {role.role === 'admin' ? ( + admin 角色拥有全部 {allPermissions.length} 个权限 + ) : editing === role.role ? ( + allPermissions.map(p => ( + + )) + ) : ( + role.permissions.filter(p => p.enabled).map(p => ( + + {permLabels[p.key] || p.key} + + )) + )} +
+
+ ))} +
+ )}
) } diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index a68ccd1..45e576b 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [editingId, setEditingId] = useState(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 (
-

用户管理

-
-

用户管理页面(本地测试通过后完善 UI)

+ {toast && ( +
+ {toast.msg} +
+ )} + +
+
+

用户管理

+

管理用户角色和权限

+
+ +
+ +
+ + + + + + + + + + + + + {loading ? ( + + ) : users.map(user => ( + + + + + + + + + ))} + +
用户名显示名邮箱角色状态操作
加载中...
{user.username}{user.display_name || '—'}{user.email || '—'} + {editingId === user.id ? ( + + ) : ( + {user.role} + )} + + + {user.is_active ? '启用' : '禁用'} + + + {editingId === user.id ? ( +
+ + +
+ ) : ( + + )} +
) diff --git a/src/app/api/admin/audit-logs/route.ts b/src/app/api/admin/audit-logs/route.ts new file mode 100644 index 0000000..902f1c1 --- /dev/null +++ b/src/app/api/admin/audit-logs/route.ts @@ -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} 天前的日志` }) +} diff --git a/src/app/api/admin/roles/route.ts b/src/app/api/admin/roles/route.ts new file mode 100644 index 0000000..f1afee1 --- /dev/null +++ b/src/app/api/admin/roles/route.ts @@ -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 }) +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..51b975c --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -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 }) +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..66ffa07 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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) +}