feat: frontend auth flow (login, user management, audit logs, route guards)
This commit is contained in:
+61
-16
@@ -1,5 +1,6 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense, type ReactNode } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { AuthProvider, useAuth } from './hooks/useAuth'
|
||||||
import Skeleton from './components/ui/Skeleton'
|
import Skeleton from './components/ui/Skeleton'
|
||||||
import AppLayout from './components/layout/AppLayout'
|
import AppLayout from './components/layout/AppLayout'
|
||||||
import LoginPage from './pages/LoginPage'
|
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 <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirect to /dashboard if already authenticated. */
|
||||||
|
function RedirectIfAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/dashboard" replace />
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<Routes>
|
||||||
<Routes>
|
<Route
|
||||||
<Route path="/login" element={<LoginPage />} />
|
path="/login"
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
element={
|
||||||
<Route
|
<RedirectIfAuth>
|
||||||
path="/"
|
<LoginPage />
|
||||||
element={
|
</RedirectIfAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -43,15 +74,29 @@ export default function App() {
|
|||||||
<Route path="cron" element={<CronPage />} />
|
<Route path="cron" element={<CronPage />} />
|
||||||
<Route path="keys" element={<KeysPage />} />
|
<Route path="keys" element={<KeysPage />} />
|
||||||
<Route path="events" element={<EventArchivePage />} />
|
<Route path="events" element={<EventArchivePage />} />
|
||||||
<Route path="users" element={<UserManagementPage />} />
|
{user?.role === 'owner' && (
|
||||||
<Route path="audit" element={<AuditLogPage />} />
|
<>
|
||||||
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
|
<Route path="audit" element={<AuditLogPage />} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
}
|
</RequireAuth>
|
||||||
/>
|
}
|
||||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
/>
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const getAuditLogs = (params?: { page?: number; page_size?: number; action?: string }) =>
|
||||||
|
client.get('/audit', { params })
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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`)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
import Badge from '../ui/Badge'
|
import Badge from '../ui/Badge'
|
||||||
import SearchBox from '../ui/SearchBox'
|
import SearchBox from '../ui/SearchBox'
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ export default function Header({
|
|||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -67,13 +70,33 @@ export default function Header({
|
|||||||
{theme === 'dark' ? '☀️' : '🌙'}
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* User avatar */}
|
{/* User avatar + role */}
|
||||||
<div
|
<div
|
||||||
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-[13px] font-semibold cursor-pointer shrink-0"
|
className="flex items-center gap-2 cursor-pointer shrink-0 relative group"
|
||||||
style={{ background: 'var(--honey-glow)', color: 'var(--honey)' }}
|
|
||||||
title="管理员"
|
|
||||||
>
|
>
|
||||||
A
|
<div
|
||||||
|
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-[13px] font-semibold"
|
||||||
|
style={{ background: 'var(--honey-glow)', color: 'var(--honey)' }}
|
||||||
|
title={user?.role ?? ''}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] hidden sm:inline" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
{user?.role}
|
||||||
|
</span>
|
||||||
|
{/* Dropdown */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 w-[120px] rounded-[var(--radius)] shadow-[var(--shadow)] py-1 hidden group-hover:block z-50"
|
||||||
|
style={{ background: 'var(--bg-nav)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { logout(); navigate('/login') }}
|
||||||
|
className="w-full text-left px-3 py-[6px] text-[13px] hover:bg-[var(--bg-hover)] transition-colors"
|
||||||
|
style={{ color: 'var(--red)' }}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
import Badge from '../ui/Badge'
|
import Badge from '../ui/Badge'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -6,6 +6,7 @@ interface NavItem {
|
|||||||
icon: string
|
icon: string
|
||||||
path: string
|
path: string
|
||||||
badge?: number
|
badge?: number
|
||||||
|
requiredRole?: string // viewer can see all by default
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavSection {
|
interface NavSection {
|
||||||
@@ -18,7 +19,7 @@ const sections: NavSection[] = [
|
|||||||
title: '蜂群态势',
|
title: '蜂群态势',
|
||||||
items: [
|
items: [
|
||||||
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
|
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
|
||||||
{ label: 'Gateway', icon: '📡', path: '/gateway' },
|
{ label: 'Gateway', icon: '📡', path: '/gateway', requiredRole: 'operator' },
|
||||||
{ label: '健康分', icon: '🏥', path: '/health' },
|
{ label: '健康分', icon: '🏥', path: '/health' },
|
||||||
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
|
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
|
||||||
],
|
],
|
||||||
@@ -27,7 +28,7 @@ const sections: NavSection[] = [
|
|||||||
title: '任务编排',
|
title: '任务编排',
|
||||||
items: [
|
items: [
|
||||||
{ label: '看板追踪', icon: '📋', path: '/kanban' },
|
{ label: '看板追踪', icon: '📋', path: '/kanban' },
|
||||||
{ label: 'Cron', icon: '⏰', path: '/cron' },
|
{ label: 'Cron', icon: '⏰', path: '/cron', requiredRole: 'operator' },
|
||||||
{ label: '事件管理', icon: '🔔', path: '/events' },
|
{ label: '事件管理', icon: '🔔', path: '/events' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -38,7 +39,7 @@ const sections: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: '运维工具',
|
title: '运维工具',
|
||||||
items: [
|
items: [
|
||||||
{ label: '密钥', icon: '🔑', path: '/keys' },
|
{ label: '密钥', icon: '🔑', path: '/keys', requiredRole: 'operator' },
|
||||||
{ label: '文件', icon: '📁', path: '/files' },
|
{ label: '文件', icon: '📁', path: '/files' },
|
||||||
{ label: '终端', icon: '💻', path: '/terminal' },
|
{ label: '终端', icon: '💻', path: '/terminal' },
|
||||||
{ label: '日志', icon: '📜', path: '/logs' },
|
{ label: '日志', icon: '📜', path: '/logs' },
|
||||||
@@ -52,7 +53,11 @@ const sections: NavSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '系统',
|
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
|
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<string, number> = { 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) {
|
export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
|
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -78,45 +94,50 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
|
|||||||
|
|
||||||
{/* Nav sections */}
|
{/* Nav sections */}
|
||||||
<div className="flex-1 overflow-y-auto px-3 pb-4">
|
<div className="flex-1 overflow-y-auto px-3 pb-4">
|
||||||
{sections.map(section => (
|
{sections.map(section => {
|
||||||
<div key={section.title} className="mt-4">
|
const visible = section.items.filter(item => hasAccess(user?.role, item.requiredRole))
|
||||||
<div
|
if (visible.length === 0) return null
|
||||||
className="px-2 mb-1 text-[11px] font-semibold uppercase tracking-wider"
|
|
||||||
style={{ color: 'var(--text-muted)' }}
|
return (
|
||||||
>
|
<div key={section.title} className="mt-4">
|
||||||
{section.title}
|
<div
|
||||||
|
className="px-2 mb-1 text-[11px] font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</div>
|
||||||
|
{visible.map(item => {
|
||||||
|
const isActive = activePath === item.path
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
className="flex items-center justify-between rounded-[var(--radius-sm)] cursor-pointer px-2 py-[7px] text-[13px] transition-colors duration-100"
|
||||||
|
style={{
|
||||||
|
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
background: isActive ? 'var(--honey-glow)' : undefined,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
if (!isActive) e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
onClick={() => onNavigate(item.path)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span style={{ marginRight: 8 }}>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{item.badge != null && item.badge > 0 && (
|
||||||
|
<Badge variant="danger">{item.badge}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{section.items.map(item => {
|
)
|
||||||
const isActive = activePath === item.path
|
})}
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.path}
|
|
||||||
className="flex items-center justify-between rounded-[var(--radius-sm)] cursor-pointer px-2 py-[7px] text-[13px] transition-colors duration-100"
|
|
||||||
style={{
|
|
||||||
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
background: isActive ? 'var(--honey-glow)' : undefined,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
if (!isActive) e.currentTarget.style.background = 'transparent'
|
|
||||||
}}
|
|
||||||
onClick={() => onNavigate(item.path)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<span style={{ marginRight: 8 }}>{item.icon}</span>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
{item.badge != null && item.badge > 0 && (
|
|
||||||
<Badge variant="danger">{item.badge}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<void>
|
||||||
|
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<AuthContextType | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem('access_token'))
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
|
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextType {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -1,3 +1,139 @@
|
|||||||
export default function AuditLogPage() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>操作审计记录</p></div>
|
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<AuditRecord[]>([])
|
||||||
|
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<string, unknown> = { 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 (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2>
|
||||||
|
<p style={{ color: 'var(--text-dim)', marginTop: 4, marginBottom: 16, fontSize: 13 }}>
|
||||||
|
操作审计记录
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<label className="text-[12px] font-semibold" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
操作筛选:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => handleFilterChange(e.target.value)}
|
||||||
|
className="px-3 py-[7px] rounded-[var(--radius-sm)] text-[13px] outline-none"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-root)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部</option>
|
||||||
|
{ACTION_OPTIONS.filter(Boolean).map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{a}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table columns={columns} data={data} rowKey="created_at" />
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
||||||
|
第 {page} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!hasMore || loading}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
|
||||||
<div
|
<div
|
||||||
className="p-8 rounded-[var(--radius)] w-[360px]"
|
className="p-8 rounded-[var(--radius)] w-[360px]"
|
||||||
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
|
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
|
||||||
>
|
>
|
||||||
<h1 className="text-center mb-6" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
|
{/* Logo */}
|
||||||
|
<h1 className="text-center" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
|
||||||
🐝 知微蜂巢
|
🐝 知微蜂巢
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-center" style={{ color: 'var(--text-dim)', fontSize: 13 }}>登录页面(待实现)</p>
|
<p className="text-center mt-1" style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
||||||
|
Hive Manage v3
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-3 rounded-[var(--radius)] text-[13px] font-medium"
|
||||||
|
style={{ background: 'var(--bg-error)', color: 'var(--red)' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[12px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[12px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" loading={loading} className="w-full !py-[9px]">
|
||||||
|
登入
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Register link */}
|
||||||
|
<p className="text-center mt-4 text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
还没有账号?{' '}
|
||||||
|
<Link to="/register" style={{ color: 'var(--honey)', fontWeight: 600 }}>
|
||||||
|
注册
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,160 @@
|
|||||||
export default function UserManagementPage() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>用户管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>用户管理与权限</p></div>
|
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<UserRecord[]>([])
|
||||||
|
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 ? (
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
onChange={(e) => handleRoleChange(u.id, e.target.value)}
|
||||||
|
className="px-2 py-1 rounded text-[12px] outline-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-root)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ROLE_OPTIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Badge variant={u.role === 'owner' ? 'honey' : u.role === 'operator' ? 'info' : 'outline'}>
|
||||||
|
{u.role}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
status: u.is_active ? (
|
||||||
|
<Badge variant="success">active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="danger">inactive</Badge>
|
||||||
|
),
|
||||||
|
approve: u.is_approved ? (
|
||||||
|
<Badge variant="success">已审批</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="warning">待审批</Badge>
|
||||||
|
),
|
||||||
|
created_at: new Date(u.created_at).toLocaleString('zh-CN'),
|
||||||
|
actions: isOwner ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!u.is_approved && (
|
||||||
|
<Button size="sm" onClick={() => handleApprove(u.id)}>
|
||||||
|
审批
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>用户管理</h2>
|
||||||
|
<p style={{ color: 'var(--text-dim)', marginTop: 4, marginBottom: 16, fontSize: 13 }}>
|
||||||
|
用户管理与权限
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Table columns={columns} data={data} rowKey="username" />
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
||||||
|
第 {page} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!hasMore || loading}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user