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()
+ })
+})