feat(web): ProtectedRoute extraído + router migrado + CreateUserPage cleanup [UDT-006]
This commit is contained in:
48
src/web/src/components/routing/ProtectedRoute.tsx
Normal file
48
src/web/src/components/routing/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
export interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
/** OR semantics — user's role must be in this list (at least one match). */
|
||||
requiredRoles?: string[]
|
||||
/** OR semantics — user must have at least one of these permission codes. */
|
||||
requiredPermissions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a route with authentication and optional authorization guards.
|
||||
*
|
||||
* Guard order:
|
||||
* 1. No user → redirect to /login
|
||||
* 2. requiredRoles provided and user.rol not in list → redirect to /
|
||||
* 3. requiredPermissions provided and user has none of them → redirect to /
|
||||
* 4. All checks pass → render children
|
||||
*/
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
requiredRoles,
|
||||
requiredPermissions,
|
||||
}: ProtectedRouteProps) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
// 1. Authentication check
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// 2. Role check (OR: user.rol must be included in the list)
|
||||
if (requiredRoles && requiredRoles.length > 0 && !requiredRoles.includes(user.rol)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
// 3. Permission check (OR: user must have at least one of the required codes)
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
const hasPermission = requiredPermissions.some((p) => user.permisos.includes(p))
|
||||
if (!hasPermission) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { UserForm } from '../components/UserForm'
|
||||
import {
|
||||
Card,
|
||||
@@ -12,13 +11,6 @@ import type { CreatedUserDto } from '../api/createUser'
|
||||
|
||||
export function CreateUserPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
// Guard: only admins can access this page
|
||||
if (!user || user.rol !== 'admin') {
|
||||
void navigate('/', { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
function handleSuccess(_created: CreatedUserDto) {
|
||||
void navigate('/')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { ProtectedRoute } from './components/routing/ProtectedRoute'
|
||||
import { LoginPage } from './features/auth/pages/LoginPage'
|
||||
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
||||
import { RolesPage } from './features/roles/pages/RolesPage'
|
||||
@@ -10,14 +11,6 @@ import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
if (user) {
|
||||
@@ -52,7 +45,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/usuarios/nuevo"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<CreateUserPage />
|
||||
</ProtectedLayout>
|
||||
@@ -62,7 +55,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/roles"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<RolesPage />
|
||||
</ProtectedLayout>
|
||||
@@ -72,7 +65,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/roles/nuevo"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<NewRolPage />
|
||||
</ProtectedLayout>
|
||||
@@ -82,7 +75,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/roles/:codigo/editar"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<EditRolPage />
|
||||
</ProtectedLayout>
|
||||
@@ -92,7 +85,12 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/permisos"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute
|
||||
requiredPermissions={[
|
||||
'administracion:roles_permisos:gestionar',
|
||||
'administracion:permisos:ver',
|
||||
]}
|
||||
>
|
||||
<ProtectedLayout>
|
||||
<RolPermisosPage />
|
||||
</ProtectedLayout>
|
||||
|
||||
280
src/web/src/tests/features/auth/ProtectedRoute.test.tsx
Normal file
280
src/web/src/tests/features/auth/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
import { ProtectedRoute } from '../../../components/routing/ProtectedRoute'
|
||||
|
||||
// Helper components for testing
|
||||
function HomePage() {
|
||||
return <div>Home Page</div>
|
||||
}
|
||||
|
||||
function SecurePage() {
|
||||
return <div>Secure Page</div>
|
||||
}
|
||||
|
||||
// Renders ProtectedRoute at a given path with optional navigation target capture
|
||||
function renderProtected(
|
||||
props: {
|
||||
requiredRoles?: string[]
|
||||
requiredPermissions?: string[]
|
||||
children?: React.ReactNode
|
||||
},
|
||||
{ initialPath = '/' }: { initialPath?: string } = {},
|
||||
) {
|
||||
const { children, ...routeProps } = props
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute {...routeProps}>
|
||||
{children ?? <SecurePage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null })
|
||||
})
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
it('F-03-01: sin user → redirect a /login', () => {
|
||||
useAuthStore.setState({ user: null })
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Secure Page')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-03-02: user autenticado sin restricciones → renderiza children', () => {
|
||||
useAuthStore.setState({
|
||||
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Secure Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-03-03: requiredRoles coincide → renderiza children', () => {
|
||||
useAuthStore.setState({
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['administracion:usuarios:gestionar'],
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['admin']}>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Secure Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-03-04: requiredRoles no coincide → redirect a /', () => {
|
||||
useAuthStore.setState({
|
||||
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['admin']}>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Root Page')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Secure Page')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-03-05: requiredPermissions OR — user tiene uno → renderiza children', () => {
|
||||
useAuthStore.setState({
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'cajero',
|
||||
nombre: 'Cajero',
|
||||
rol: 'cajero',
|
||||
permisos: ['ventas:contado:crear'],
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
requiredPermissions={['ventas:contado:crear', 'administracion:usuarios:gestionar']}
|
||||
>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Secure Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-03-06: requiredPermissions — user no tiene ninguno → redirect a /', () => {
|
||||
useAuthStore.setState({
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'cajero',
|
||||
nombre: 'Cajero',
|
||||
rol: 'cajero',
|
||||
permisos: ['ventas:contado:crear'],
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Root Page')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Secure Page')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-03-07: usuario admin puede acceder a ruta con requiredPermissions', () => {
|
||||
useAuthStore.setState({
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['administracion:usuarios:gestionar'],
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/usuarios/nuevo']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/usuarios/nuevo"
|
||||
element={
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<div>Create User Page</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Create User Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-03-08: usuario cajero sin permiso no puede acceder a /usuarios/nuevo → redirect a /', () => {
|
||||
useAuthStore.setState({
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'cajero',
|
||||
nombre: 'Cajero',
|
||||
rol: 'cajero',
|
||||
permisos: ['ventas:contado:crear'],
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/usuarios/nuevo']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/usuarios/nuevo"
|
||||
element={
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<div>Create User Page</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Root Page')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Create User Page')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user