From 8935115da9c056ac201fc27534fae057ce3b0324 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:40:23 -0300 Subject: [PATCH] feat(web): usePermission + CanPerform [UDT-006] --- src/web/src/components/auth/CanPerform.tsx | 19 ++++ .../src/features/auth/hooks/usePermission.ts | 15 +++ .../tests/features/auth/CanPerform.test.tsx | 103 ++++++++++++++++++ .../tests/features/auth/usePermission.test.ts | 96 ++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/web/src/components/auth/CanPerform.tsx create mode 100644 src/web/src/features/auth/hooks/usePermission.ts create mode 100644 src/web/src/tests/features/auth/CanPerform.test.tsx create mode 100644 src/web/src/tests/features/auth/usePermission.test.ts 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) + }) +})