feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs

This commit is contained in:
2026-04-02 11:20:20 +08:00
parent dcc1f186f8
commit 4718980ab5
235 changed files with 35682 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
/**
* RequireAdmin - 管理员守卫
*
* 非管理员时跳转到个人资料页。
* 修复:加入 isLoading 检查,避免会话恢复期间误跳转。
*/
import { Navigate } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import type { ReactNode } from 'react'
interface RequireAdminProps {
children: ReactNode
}
export function RequireAdmin({ children }: RequireAdminProps) {
const { isAdmin, isLoading } = useAuth()
// 会话恢复中,等待完成再判断
if (isLoading) {
return null
}
// 非管理员,跳转到个人资料页
if (!isAdmin) {
return <Navigate to="/profile" replace />
}
return children
}

View File

@@ -0,0 +1,40 @@
/**
* RequireAuth - 登录守卫
*
* 未登录时跳转到登录页
*/
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import { Spin } from 'antd'
import type { ReactNode } from 'react'
interface RequireAuthProps {
children: ReactNode
}
export function RequireAuth({ children }: RequireAuthProps) {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
// 加载中显示 loading
if (isLoading) {
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Spin size="large" />
</div>
)
}
// 未登录,跳转到登录页
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return children
}

View File

@@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
import type { ReactNode } from 'react'
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
import { RequireAdmin } from './RequireAdmin'
import { RequireAuth } from './RequireAuth'
const baseAuthContextValue: AuthContextValue = {
user: null,
roles: [],
isAdmin: false,
isAuthenticated: false,
isLoading: false,
onLoginSuccess: async () => {},
logout: async () => {},
refreshUser: async () => {},
}
function LocationProbe() {
const location = useLocation()
const fromPath =
(location.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? 'none'
return (
<div>
<span data-testid="pathname">{location.pathname}</span>
<span data-testid="from-path">{fromPath}</span>
</div>
)
}
function renderWithAuth(
authContextValue: Partial<AuthContextValue>,
router: ReactNode,
) {
const value: AuthContextValue = {
...baseAuthContextValue,
...authContextValue,
}
return render(
<AuthContext.Provider value={value}>
{router}
</AuthContext.Provider>,
)
}
describe('RequireAuth', () => {
it('shows a loading indicator while auth state is being restored', () => {
const { container } = renderWithAuth(
{ isLoading: true },
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route
path="/users"
element={(
<RequireAuth>
<div>private content</div>
</RequireAuth>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(screen.queryByText('private content')).not.toBeInTheDocument()
})
it('redirects unauthenticated users to login and preserves the original route', async () => {
renderWithAuth(
{ isAuthenticated: false, isLoading: false },
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route
path="/users"
element={(
<RequireAuth>
<div>private content</div>
</RequireAuth>
)}
/>
<Route path="/login" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
)
expect(await screen.findByTestId('pathname')).toHaveTextContent('/login')
expect(screen.getByTestId('from-path')).toHaveTextContent('/users')
})
it('renders protected content when authenticated', () => {
renderWithAuth(
{
isAuthenticated: true,
isLoading: false,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route
path="/users"
element={(
<RequireAuth>
<div>private content</div>
</RequireAuth>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('private content')).toBeInTheDocument()
})
})
describe('RequireAdmin', () => {
it('waits silently while auth state is still loading', () => {
const { container } = renderWithAuth(
{ isLoading: true, isAdmin: false },
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route
path="/dashboard"
element={(
<RequireAdmin>
<div>admin dashboard</div>
</RequireAdmin>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(container).toBeEmptyDOMElement()
})
it('redirects non-admin users to profile', async () => {
renderWithAuth(
{ isLoading: false, isAdmin: false, isAuthenticated: true },
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route
path="/dashboard"
element={(
<RequireAdmin>
<div>admin dashboard</div>
</RequireAdmin>
)}
/>
<Route path="/profile" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
)
expect(await screen.findByTestId('pathname')).toHaveTextContent('/profile')
})
it('renders admin-only content for admins', () => {
renderWithAuth(
{ isLoading: false, isAdmin: true, isAuthenticated: true },
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route
path="/dashboard"
element={(
<RequireAdmin>
<div>admin dashboard</div>
</RequireAdmin>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('admin dashboard')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,2 @@
export { RequireAuth } from './RequireAuth'
export { RequireAdmin } from './RequireAdmin'