diff --git a/src/web/src/components/auth/CanPerform.tsx b/src/web/src/components/auth/CanPerform.tsx
new file mode 100644
index 0000000..02cc7cd
--- /dev/null
+++ b/src/web/src/components/auth/CanPerform.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from 'react'
+import { usePermission } from '@/features/auth/hooks/usePermission'
+
+interface CanPerformProps {
+ /** Permission code or array of codes (OR: at least one must match). */
+ permission: string | string[]
+ /** Rendered when user lacks the permission. Defaults to null. */
+ fallback?: ReactNode
+ children: ReactNode
+}
+
+/**
+ * Renders children if the authenticated user has the required permission(s).
+ * When permission is an array, OR semantics apply — one match is sufficient.
+ * Renders fallback (or null) otherwise.
+ */
+export function CanPerform({ permission, fallback = null, children }: CanPerformProps) {
+ return usePermission(permission) ? <>{children}> : <>{fallback}>
+}
diff --git a/src/web/src/features/auth/hooks/usePermission.ts b/src/web/src/features/auth/hooks/usePermission.ts
new file mode 100644
index 0000000..b0f050b
--- /dev/null
+++ b/src/web/src/features/auth/hooks/usePermission.ts
@@ -0,0 +1,15 @@
+import { useAuthStore } from '@/stores/authStore'
+
+/**
+ * Returns true if the authenticated user has at least one of the given permission codes.
+ * OR semantics when passing an array — at least one match suffices.
+ * Returns false when the user is not authenticated or has no matching permission.
+ */
+export function usePermission(code: string | string[]): boolean {
+ // Select the user directly to avoid creating a new array reference on every render
+ // when user is null (which would cause infinite re-renders in React 19).
+ const user = useAuthStore((s) => s.user)
+ const permisos = user?.permisos ?? []
+ const wanted = Array.isArray(code) ? code : [code]
+ return wanted.some((c) => permisos.includes(c))
+}
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/usePermission.test.ts b/src/web/src/tests/features/auth/usePermission.test.ts
new file mode 100644
index 0000000..e5bfdb8
--- /dev/null
+++ b/src/web/src/tests/features/auth/usePermission.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useAuthStore } from '../../../stores/authStore'
+import { usePermission } from '../../../features/auth/hooks/usePermission'
+
+beforeEach(() => {
+ useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null })
+})
+
+describe('usePermission', () => {
+ it('F-01-01: user con permiso exacto → true', () => {
+ useAuthStore.setState({
+ user: {
+ id: 1,
+ username: 'admin',
+ nombre: 'Admin',
+ rol: 'admin',
+ permisos: ['administracion:usuarios:gestionar'],
+ },
+ })
+
+ const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar'))
+ expect(result.current).toBe(true)
+ })
+
+ it('F-01-02: user sin ese permiso → false', () => {
+ useAuthStore.setState({
+ user: {
+ id: 2,
+ username: 'cajero',
+ nombre: 'Cajero',
+ rol: 'cajero',
+ permisos: ['ventas:contado:crear'],
+ },
+ })
+
+ const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar'))
+ expect(result.current).toBe(false)
+ })
+
+ it('F-01-03: user con permisos vacíos → false', () => {
+ useAuthStore.setState({
+ user: {
+ id: 3,
+ username: 'reportes',
+ nombre: 'Reportes',
+ rol: 'reportes',
+ permisos: [],
+ },
+ })
+
+ const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar'))
+ expect(result.current).toBe(false)
+ })
+
+ it('F-01-04: sin user (null) → false', () => {
+ useAuthStore.setState({ user: null })
+
+ const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar'))
+ expect(result.current).toBe(false)
+ })
+
+ it('array input OR: true si alguno de los permisos hace match', () => {
+ useAuthStore.setState({
+ user: {
+ id: 2,
+ username: 'cajero',
+ nombre: 'Cajero',
+ rol: 'cajero',
+ permisos: ['ventas:contado:crear'],
+ },
+ })
+
+ const { result } = renderHook(() =>
+ usePermission(['ventas:contado:crear', 'administracion:usuarios:gestionar']),
+ )
+ expect(result.current).toBe(true)
+ })
+
+ it('array input OR: false si ninguno hace match', () => {
+ useAuthStore.setState({
+ user: {
+ id: 2,
+ username: 'cajero',
+ nombre: 'Cajero',
+ rol: 'cajero',
+ permisos: ['ventas:contado:crear'],
+ },
+ })
+
+ const { result } = renderHook(() =>
+ usePermission(['administracion:usuarios:gestionar', 'administracion:roles:gestionar']),
+ )
+ expect(result.current).toBe(false)
+ })
+})