diff --git a/src/web/src/features/roles/pages/RolesPage.tsx b/src/web/src/features/roles/pages/RolesPage.tsx
index 3741377..d13e8a7 100644
--- a/src/web/src/features/roles/pages/RolesPage.tsx
+++ b/src/web/src/features/roles/pages/RolesPage.tsx
@@ -1,5 +1,4 @@
-import { Link, useNavigate } from 'react-router-dom'
-import { useAuthStore } from '@/stores/authStore'
+import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import {
Card,
@@ -11,14 +10,6 @@ import {
import { RolesList } from '../components/RolesList'
export function RolesPage() {
- const navigate = useNavigate()
- const user = useAuthStore((s) => s.user)
-
- if (!user || user.rol !== 'admin') {
- void navigate('/', { replace: true })
- return null
- }
-
return (
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/stores/authStore.ts b/src/web/src/stores/authStore.ts
index cfa8a68..7e7ebec 100644
--- a/src/web/src/stores/authStore.ts
+++ b/src/web/src/stores/authStore.ts
@@ -6,6 +6,7 @@ export interface AuthUser {
username: string
nombre: string
rol: string
+ permisos: string[]
}
interface SetAuthPayload {
diff --git a/src/web/src/tests/features/auth/CanPerform.test.tsx b/src/web/src/tests/features/auth/CanPerform.test.tsx
new file mode 100644
index 0000000..d6f6b5e
--- /dev/null
+++ b/src/web/src/tests/features/auth/CanPerform.test.tsx
@@ -0,0 +1,103 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { useAuthStore } from '../../../stores/authStore'
+import { CanPerform } from '../../../components/auth/CanPerform'
+
+beforeEach(() => {
+ useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null })
+})
+
+describe('CanPerform', () => {
+ it('F-02-01: usuario con permiso → renderiza children', () => {
+ useAuthStore.setState({
+ user: {
+ id: 1,
+ username: 'admin',
+ nombre: 'Admin',
+ rol: 'admin',
+ permisos: ['administracion:usuarios:gestionar'],
+ },
+ })
+
+ render(
+
+ Acción
+ ,
+ )
+
+ expect(screen.getByText('Acción')).toBeInTheDocument()
+ })
+
+ it('F-02-02: usuario sin permiso → no renderiza children (null)', () => {
+ useAuthStore.setState({
+ user: {
+ id: 2,
+ username: 'cajero',
+ nombre: 'Cajero',
+ rol: 'cajero',
+ permisos: ['ventas:contado:crear'],
+ },
+ })
+
+ render(
+
+ Acción
+ ,
+ )
+
+ expect(screen.queryByText('Acción')).not.toBeInTheDocument()
+ })
+
+ it('F-02-03: sin user → no renderiza children', () => {
+ useAuthStore.setState({ user: null })
+
+ render(
+
+ Acción
+ ,
+ )
+
+ expect(screen.queryByText('Acción')).not.toBeInTheDocument()
+ })
+
+ it('F-02-04: sin permiso pero con fallback → renderiza fallback, NO children', () => {
+ useAuthStore.setState({
+ user: {
+ id: 3,
+ username: 'reportes',
+ nombre: 'Reportes',
+ rol: 'reportes',
+ permisos: [],
+ },
+ })
+
+ render(
+ Sin acceso}>
+ Acción
+ ,
+ )
+
+ expect(screen.getByText('Sin acceso')).toBeInTheDocument()
+ expect(screen.queryByText('Acción')).not.toBeInTheDocument()
+ })
+
+ it('array permission OR: renderiza si tiene alguno', () => {
+ useAuthStore.setState({
+ user: {
+ id: 2,
+ username: 'cajero',
+ nombre: 'Cajero',
+ rol: 'cajero',
+ permisos: ['ventas:contado:crear'],
+ },
+ })
+
+ render(
+
+ Acción
+ ,
+ )
+
+ expect(screen.getByText('Acción')).toBeInTheDocument()
+ })
+})
diff --git a/src/web/src/tests/features/auth/LoginPage.test.tsx b/src/web/src/tests/features/auth/LoginPage.test.tsx
index 8be4e3d..d99c99b 100644
--- a/src/web/src/tests/features/auth/LoginPage.test.tsx
+++ b/src/web/src/tests/features/auth/LoginPage.test.tsx
@@ -21,7 +21,7 @@ const mockLoginResponse = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
refreshToken: 'refresh-token-abc',
expiresIn: 3600,
- usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
+ usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] },
}
const server = setupServer(
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 } />
+