diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa34f64..6eea1e8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ -import { lazy, Suspense } from 'react' -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { lazy, Suspense, type ReactNode } from 'react' +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' +import { AuthProvider, useAuth } from './hooks/useAuth' import Skeleton from './components/ui/Skeleton' import AppLayout from './components/layout/AppLayout' import LoginPage from './pages/LoginPage' @@ -23,15 +24,45 @@ function Loading() { ) } -export default function App() { +/** Redirect to /login if not authenticated. */ +function RequireAuth({ children }: { children: ReactNode }) { + const { isAuthenticated } = useAuth() + const location = useLocation() + + if (!isAuthenticated) { + return + } + return <>{children} +} + +/** Redirect to /dashboard if already authenticated. */ +function RedirectIfAuth({ children }: { children: ReactNode }) { + const { isAuthenticated } = useAuth() + + if (isAuthenticated) { + return + } + return <>{children} +} + +function AppRoutes() { + const { user } = useAuth() + return ( - - - } /> - } /> - + + + + } + /> + } /> + }> @@ -43,15 +74,29 @@ export default function App() { } /> } /> } /> - } /> - } /> + {user?.role === 'owner' && ( + <> + } /> + } /> + + )} - } - /> - } /> - + + } + /> + } /> + + ) +} + +export default function App() { + return ( + + + + ) } diff --git a/frontend/src/api/audit.ts b/frontend/src/api/audit.ts new file mode 100644 index 0000000..61174e1 --- /dev/null +++ b/frontend/src/api/audit.ts @@ -0,0 +1,4 @@ +import client from './client' + +export const getAuditLogs = (params?: { page?: number; page_size?: number; action?: string }) => + client.get('/audit', { params }) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..2c3d526 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,12 @@ +import client from './client' + +export const login = (username: string, password: string) => + client.post('/auth/login', { username, password }) + +export const register = (data: { username: string; email: string; password: string }) => + client.post('/auth/register', data) + +export const refresh = () => client.post('/auth/refresh') + +export const changePassword = (data: { old_password: string; new_password: string }) => + client.post('/auth/change-password', data) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..a5452e6 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,27 @@ +import axios from 'axios' + +const client = axios.create({ + baseURL: '/api/v1', + headers: { 'Content-Type': 'application/json' }, +}) + +client.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +client.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('access_token') + window.location.href = '/login' + } + return Promise.reject(err) + }, +) + +export default client diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..d5971e1 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,11 @@ +import client from './client' + +export const getUsers = (params?: { page?: number; page_size?: number }) => + client.get('/users', { params }) + +export const getUser = (id: string) => client.get(`/users/${id}`) + +export const updateUser = (id: string, data: { role?: string; is_active?: boolean }) => + client.patch(`/users/${id}`, data) + +export const approveUser = (id: string) => client.post(`/users/${id}/approve`) diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 0df7563..e8db7cf 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,4 +1,5 @@ import { useNavigate } from 'react-router-dom' +import { useAuth } from '../../hooks/useAuth' import Badge from '../ui/Badge' import SearchBox from '../ui/SearchBox' @@ -20,6 +21,8 @@ export default function Header({ onToggleTheme, }: HeaderProps) { const navigate = useNavigate() + const { user, logout } = useAuth() + const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?' return (
- {/* User avatar */} + {/* User avatar + role */}
- A +
+ {initial} +
+ + {user?.role} + + {/* Dropdown */} +
+ +
) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2ce006b..a3db0dc 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react' +import { useAuth } from '../../hooks/useAuth' import Badge from '../ui/Badge' interface NavItem { @@ -6,6 +6,7 @@ interface NavItem { icon: string path: string badge?: number + requiredRole?: string // viewer can see all by default } interface NavSection { @@ -18,7 +19,7 @@ const sections: NavSection[] = [ title: '蜂群态势', items: [ { label: '蜂群总览', icon: '📊', path: '/dashboard' }, - { label: 'Gateway', icon: '📡', path: '/gateway' }, + { label: 'Gateway', icon: '📡', path: '/gateway', requiredRole: 'operator' }, { label: '健康分', icon: '🏥', path: '/health' }, { label: '工蜂列表', icon: '🤖', path: '/workers' }, ], @@ -27,7 +28,7 @@ const sections: NavSection[] = [ title: '任务编排', items: [ { label: '看板追踪', icon: '📋', path: '/kanban' }, - { label: 'Cron', icon: '⏰', path: '/cron' }, + { label: 'Cron', icon: '⏰', path: '/cron', requiredRole: 'operator' }, { label: '事件管理', icon: '🔔', path: '/events' }, ], }, @@ -38,7 +39,7 @@ const sections: NavSection[] = [ { title: '运维工具', items: [ - { label: '密钥', icon: '🔑', path: '/keys' }, + { label: '密钥', icon: '🔑', path: '/keys', requiredRole: 'operator' }, { label: '文件', icon: '📁', path: '/files' }, { label: '终端', icon: '💻', path: '/terminal' }, { label: '日志', icon: '📜', path: '/logs' }, @@ -52,7 +53,11 @@ const sections: NavSection[] = [ }, { title: '系统', - items: [{ label: '设置', icon: '⚙', path: '/settings' }], + items: [ + { label: '用户管理', icon: '👥', path: '/users', requiredRole: 'owner' }, + { label: '审计日志', icon: '📋', path: '/audit', requiredRole: 'owner' }, + { label: '设置', icon: '⚙', path: '/settings' }, + ], }, ] @@ -61,7 +66,18 @@ export interface SidebarProps { onNavigate: (path: string) => void } +/** Check whether the current user's role satisfies the required role level. */ +function hasAccess(userRole: string | undefined, requiredRole?: string): boolean { + if (!requiredRole) return true + const hierarchy: Record = { viewer: 0, operator: 1, owner: 2 } + const userLevel = hierarchy[userRole ?? 'viewer'] ?? 0 + const requiredLevel = hierarchy[requiredRole] ?? 0 + return userLevel >= requiredLevel +} + export default function Sidebar({ activePath, onNavigate }: SidebarProps) { + const { user } = useAuth() + return ( ) diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx new file mode 100644 index 0000000..271e9ec --- /dev/null +++ b/frontend/src/hooks/useAuth.tsx @@ -0,0 +1,65 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react' +import * as authApi from '../api/auth' + +export interface AuthUser { + id: string + role: string +} + +export interface AuthContextType { + user: AuthUser | null + token: string | null + isAuthenticated: boolean + login: (username: string, password: string) => Promise + logout: () => void +} + +function decodeToken(token: string): AuthUser | null { + try { + const payloadBase64 = token.split('.')[1] + const payload = JSON.parse(atob(payloadBase64)) + return { id: payload.sub, role: payload.role } + } catch { + return null + } +} + +export const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(() => localStorage.getItem('access_token')) + const [user, setUser] = useState(() => { + const t = localStorage.getItem('access_token') + return t ? decodeToken(t) : null + }) + + const login = useCallback(async (username: string, password: string) => { + const res = await authApi.login(username, password) + const t: string = res.data.access_token + localStorage.setItem('access_token', t) + setToken(t) + setUser(decodeToken(t)) + }, []) + + const logout = useCallback(() => { + localStorage.removeItem('access_token') + setToken(null) + setUser(null) + }, []) + + const value: AuthContextType = { + user, + token, + isAuthenticated: !!token && !!user, + login, + logout, + } + + return {children} +} + +export function useAuth(): AuthContextType { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/frontend/src/pages/AuditLogPage.tsx b/frontend/src/pages/AuditLogPage.tsx index 8661817..b46a669 100644 --- a/frontend/src/pages/AuditLogPage.tsx +++ b/frontend/src/pages/AuditLogPage.tsx @@ -1,3 +1,139 @@ -export default function AuditLogPage() { - return

审计日志

操作审计记录

+import { useEffect, useState, useCallback } from 'react' +import { getAuditLogs } from '../api/audit' +import Table, { type TableColumn } from '../components/ui/Table' +import Button from '../components/ui/Button' +import toast from '../components/ui/Toast' + +interface AuditRecord { + id: string + username: string + action: string + target: string + detail: string | null + ip_address: string | null + created_at: string +} + +const ACTION_OPTIONS = [ + '', + 'keys.batch_overwrite', + 'gateway.toggle', + 'cron.trigger', + 'user.login', + 'user.update_role', + 'user.approve', + 'keys.generate', + 'kaban.create', + 'kaban.update', +] + +export default function AuditLogPage() { + const [logs, setLogs] = useState([]) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(false) + const [actionFilter, setActionFilter] = useState('') + + const fetchLogs = useCallback(async (p: number, action?: string) => { + setLoading(true) + try { + const params: Record = { page: p, page_size: 20 } + if (action) params.action = action + const res = await getAuditLogs(params) + const data: AuditRecord[] = res.data + setLogs(data) + setHasMore(data.length === 20) + } catch { + toast.error('加载审计日志失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchLogs(page, actionFilter) + }, [page, fetchLogs]) + + const handleFilterChange = (val: string) => { + setActionFilter(val) + setPage(1) + } + + const columns: TableColumn[] = [ + { key: 'created_at', label: '时间', width: 180 }, + { key: 'username', label: '用户', width: 100 }, + { key: 'action', label: '操作', width: 160 }, + { key: 'target', label: '目标', width: 200 }, + { key: 'detail', label: '详情' }, + { key: 'ip_address', label: 'IP', width: 140 }, + ] + + const data = logs.map((l) => ({ + created_at: new Date(l.created_at).toLocaleString('zh-CN'), + username: l.username, + action: l.action, + target: l.target, + detail: l.detail ?? '-', + ip_address: l.ip_address ?? '-', + })) + + return ( +
+

审计日志

+

+ 操作审计记录 +

+ + {/* Filter */} +
+ + +
+ + + + {/* Pagination */} +
+ + 第 {page} 页 + +
+ + +
+
+ + ) } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 37a3e6b..918a62b 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,14 +1,115 @@ +import { type FormEvent, useState } from 'react' +import { useNavigate, Link } from 'react-router-dom' +import { useAuth } from '../hooks/useAuth' +import Button from '../components/ui/Button' +import toast from '../components/ui/Toast' + export default function LoginPage() { + const { login } = useAuth() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + try { + await login(username, password) + toast.success('登录成功') + navigate('/dashboard', { replace: true }) + } catch (err: any) { + const status = err?.response?.status + const detail = err?.response?.data?.detail ?? '登录失败' + if (status === 423) { + setError('账户已锁定,请 15 分钟后再试') + } else if (status === 401) { + setError('用户名或密码错误') + } else { + setError(detail) + } + } finally { + setLoading(false) + } + } + return (
-

+ {/* Logo */} +

🐝 知微蜂巢

-

登录页面(待实现)

+

+ Hive Manage v3 +

+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Form */} +
+
+ + setUsername(e.target.value)} + placeholder="请输入用户名" + required + autoFocus + className="w-full mt-1 px-3 py-[9px] rounded-[var(--radius-sm)] text-[13px] outline-none transition-colors" + style={{ + background: 'var(--bg-root)', + color: 'var(--text)', + border: '1px solid var(--border)', + }} + /> +
+
+ + setPassword(e.target.value)} + placeholder="请输入密码" + required + className="w-full mt-1 px-3 py-[9px] rounded-[var(--radius-sm)] text-[13px] outline-none transition-colors" + style={{ + background: 'var(--bg-root)', + color: 'var(--text)', + border: '1px solid var(--border)', + }} + /> +
+ + + + {/* Register link */} +

+ 还没有账号?{' '} + + 注册 + +

) diff --git a/frontend/src/pages/UserManagementPage.tsx b/frontend/src/pages/UserManagementPage.tsx index 768b32c..1735ef3 100644 --- a/frontend/src/pages/UserManagementPage.tsx +++ b/frontend/src/pages/UserManagementPage.tsx @@ -1,3 +1,160 @@ -export default function UserManagementPage() { - return

用户管理

用户管理与权限

+import { useEffect, useState, useCallback } from 'react' +import { useAuth } from '../hooks/useAuth' +import { getUsers, updateUser, approveUser } from '../api/users' +import Table, { type TableColumn } from '../components/ui/Table' +import Button from '../components/ui/Button' +import Badge from '../components/ui/Badge' +import toast from '../components/ui/Toast' + +interface UserRecord { + id: string + username: string + email: string + role: string + is_active: boolean + is_approved: boolean + created_at: string +} + +const ROLE_OPTIONS = ['owner', 'operator', 'viewer'] + +export default function UserManagementPage() { + const { user: me } = useAuth() + const [users, setUsers] = useState([]) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(false) + + const fetchUsers = useCallback(async (p: number) => { + setLoading(true) + try { + const res = await getUsers({ page: p, page_size: 20 }) + const data: UserRecord[] = res.data + setUsers(data) + setHasMore(data.length === 20) + } catch { + toast.error('加载用户列表失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchUsers(page) + }, [page, fetchUsers]) + + const handleRoleChange = async (userId: string, newRole: string) => { + try { + await updateUser(userId, { role: newRole }) + toast.success('角色已更新') + fetchUsers(page) + } catch { + toast.error('更新角色失败') + } + } + + const handleApprove = async (userId: string) => { + try { + await approveUser(userId) + toast.success('用户已审批通过') + fetchUsers(page) + } catch { + toast.error('审批失败') + } + } + + const isOwner = me?.role === 'owner' + + const columns: TableColumn[] = [ + { key: 'username', label: '用户名' }, + { key: 'email', label: '邮箱' }, + { key: 'role', label: '角色' }, + { key: 'status', label: '状态' }, + { key: 'approve', label: '审批状态' }, + { key: 'created_at', label: '创建时间' }, + ...(isOwner ? [{ key: 'actions', label: '操作' }] : []), + ] + + const data = users.map((u) => ({ + username: u.username, + email: u.email, + role: isOwner ? ( + + ) : ( + + {u.role} + + ), + status: u.is_active ? ( + active + ) : ( + inactive + ), + approve: u.is_approved ? ( + 已审批 + ) : ( + 待审批 + ), + created_at: new Date(u.created_at).toLocaleString('zh-CN'), + actions: isOwner ? ( +
+ {!u.is_approved && ( + + )} +
+ ) : null, + })) + + return ( +
+

用户管理

+

+ 用户管理与权限 +

+ +
+ + {/* Pagination */} +
+ + 第 {page} 页 + +
+ + +
+
+ + ) }