diff --git a/src/web/src/components/routing/ProtectedRoute.tsx b/src/web/src/components/routing/ProtectedRoute.tsx new file mode 100644 index 0000000..b77cf3c --- /dev/null +++ b/src/web/src/components/routing/ProtectedRoute.tsx @@ -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 + } + + // 2. Role check (OR: user.rol must be included in the list) + if (requiredRoles && requiredRoles.length > 0 && !requiredRoles.includes(user.rol)) { + return + } + + // 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 + } + } + + return <>{children} +} diff --git a/src/web/src/features/users/pages/CreateUserPage.tsx b/src/web/src/features/users/pages/CreateUserPage.tsx index a6a05de..b97076b 100644 --- a/src/web/src/features/users/pages/CreateUserPage.tsx +++ b/src/web/src/features/users/pages/CreateUserPage.tsx @@ -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('/') diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 5f2c352..6c10ee7 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -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 - } - return <>{children} -} - function PublicRoute({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user) if (user) { @@ -52,7 +45,7 @@ export function AppRoutes() { + @@ -62,7 +55,7 @@ export function AppRoutes() { + @@ -72,7 +65,7 @@ export function AppRoutes() { + @@ -82,7 +75,7 @@ export function AppRoutes() { + @@ -92,7 +85,12 @@ export function AppRoutes() { + diff --git a/src/web/src/tests/features/auth/ProtectedRoute.test.tsx b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx new file mode 100644 index 0000000..8231c5a --- /dev/null +++ b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx @@ -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
Home Page
+} + +function SecurePage() { + return
Secure Page
+} + +// 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( + + + Login Page} /> + Root} /> + + {children ?? } +
+ } + /> + + , + ) +} + +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( + + + Login Page} /> + + +
+ } + /> + + , + ) + + 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( + + + Login Page} /> + + +
+ } + /> + + , + ) + + 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( + + + Login Page} /> + Root} /> + + +
+ } + /> + + , + ) + + 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( + + + Login Page} /> + Root Page} /> + + +
+ } + /> + + , + ) + + 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( + + + Login Page} /> + Root Page} /> + + + + } + /> + + , + ) + + 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( + + + Login Page} /> + Root Page} /> + + + + } + /> + + , + ) + + 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( + + + Login Page} /> + Root Page} /> + +
Create User Page
+ + } + /> +
+
, + ) + + 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( + + + Login Page} /> + Root Page} /> + +
Create User Page
+ + } + /> +
+
, + ) + + expect(screen.getByText('Root Page')).toBeInTheDocument() + expect(screen.queryByText('Create User Page')).not.toBeInTheDocument() + }) +})