feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
30
frontend/admin/src/components/guards/RequireAdmin.tsx
Normal file
30
frontend/admin/src/components/guards/RequireAdmin.tsx
Normal 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
|
||||
}
|
||||
40
frontend/admin/src/components/guards/RequireAuth.tsx
Normal file
40
frontend/admin/src/components/guards/RequireAuth.tsx
Normal 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
|
||||
}
|
||||
188
frontend/admin/src/components/guards/guards.test.tsx
Normal file
188
frontend/admin/src/components/guards/guards.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
2
frontend/admin/src/components/guards/index.ts
Normal file
2
frontend/admin/src/components/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RequireAuth } from './RequireAuth'
|
||||
export { RequireAdmin } from './RequireAdmin'
|
||||
Reference in New Issue
Block a user