feat(web): usePermission + CanPerform [UDT-006]

This commit is contained in:
2026-04-15 16:40:23 -03:00
parent 2efd5e2fdb
commit 8935115da9
4 changed files with 233 additions and 0 deletions

View File

@@ -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}</>
}

View File

@@ -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))
}

View File

@@ -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(
<CanPerform permission="administracion:usuarios:gestionar">
<span>Acción</span>
</CanPerform>,
)
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(
<CanPerform permission="administracion:usuarios:gestionar">
<span>Acción</span>
</CanPerform>,
)
expect(screen.queryByText('Acción')).not.toBeInTheDocument()
})
it('F-02-03: sin user → no renderiza children', () => {
useAuthStore.setState({ user: null })
render(
<CanPerform permission="administracion:usuarios:gestionar">
<span>Acción</span>
</CanPerform>,
)
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(
<CanPerform permission="administracion:usuarios:gestionar" fallback={<span>Sin acceso</span>}>
<span>Acción</span>
</CanPerform>,
)
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(
<CanPerform permission={['ventas:contado:crear', 'administracion:usuarios:gestionar']}>
<span>Acción</span>
</CanPerform>,
)
expect(screen.getByText('Acción')).toBeInTheDocument()
})
})

View File

@@ -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)
})
})