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 { useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
|
||||||
import { UserForm } from '../components/UserForm'
|
import { UserForm } from '../components/UserForm'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -12,13 +11,6 @@ import type { CreatedUserDto } from '../api/createUser'
|
|||||||
|
|
||||||
export function CreateUserPage() {
|
export function CreateUserPage() {
|
||||||
const navigate = useNavigate()
|
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) {
|
function handleSuccess(_created: CreatedUserDto) {
|
||||||
void navigate('/')
|
void navigate('/')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { useAuthStore } from './stores/authStore'
|
import { useAuthStore } from './stores/authStore'
|
||||||
|
import { ProtectedRoute } from './components/routing/ProtectedRoute'
|
||||||
import { LoginPage } from './features/auth/pages/LoginPage'
|
import { LoginPage } from './features/auth/pages/LoginPage'
|
||||||
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
||||||
import { RolesPage } from './features/roles/pages/RolesPage'
|
import { RolesPage } from './features/roles/pages/RolesPage'
|
||||||
@@ -10,14 +11,6 @@ import { HomePage } from './pages/HomePage'
|
|||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
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 }) {
|
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -52,7 +45,7 @@ export function AppRoutes() {
|
|||||||
<Route
|
<Route
|
||||||
path="/usuarios/nuevo"
|
path="/usuarios/nuevo"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||||
<ProtectedLayout>
|
<ProtectedLayout>
|
||||||
<CreateUserPage />
|
<CreateUserPage />
|
||||||
</ProtectedLayout>
|
</ProtectedLayout>
|
||||||
@@ -62,7 +55,7 @@ export function AppRoutes() {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin/roles"
|
path="/admin/roles"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||||
<ProtectedLayout>
|
<ProtectedLayout>
|
||||||
<RolesPage />
|
<RolesPage />
|
||||||
</ProtectedLayout>
|
</ProtectedLayout>
|
||||||
@@ -72,7 +65,7 @@ export function AppRoutes() {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin/roles/nuevo"
|
path="/admin/roles/nuevo"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||||
<ProtectedLayout>
|
<ProtectedLayout>
|
||||||
<NewRolPage />
|
<NewRolPage />
|
||||||
</ProtectedLayout>
|
</ProtectedLayout>
|
||||||
@@ -82,7 +75,7 @@ export function AppRoutes() {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin/roles/:codigo/editar"
|
path="/admin/roles/:codigo/editar"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||||
<ProtectedLayout>
|
<ProtectedLayout>
|
||||||
<EditRolPage />
|
<EditRolPage />
|
||||||
</ProtectedLayout>
|
</ProtectedLayout>
|
||||||
@@ -92,7 +85,12 @@ export function AppRoutes() {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin/permisos"
|
path="/admin/permisos"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute
|
||||||
|
requiredPermissions={[
|
||||||
|
'administracion:roles_permisos:gestionar',
|
||||||
|
'administracion:permisos:ver',
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ProtectedLayout>
|
<ProtectedLayout>
|
||||||
<RolPermisosPage />
|
<RolPermisosPage />
|
||||||
</ProtectedLayout>
|
</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