UDT-005: Gestión de Permisos (RBAC) — catálogo + asignación rol↔permisos #9

Merged
dmolinari merged 8 commits from feature/UDT-005 into main 2026-04-15 19:02:03 +00:00
12 changed files with 483 additions and 0 deletions
Showing only changes of commit 885a8cef17 - Show all commits

View File

@@ -7,6 +7,7 @@ import {
Settings,
UserPlus,
ShieldCheck,
KeyRound,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
@@ -117,6 +118,18 @@ export function SidebarNav() {
<ShieldCheck className="h-4 w-4 shrink-0" />
<span>Roles</span>
</Link>
<Link
to="/admin/permisos"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
pathname.startsWith('/admin/permisos')
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<KeyRound className="h-4 w-4 shrink-0" />
<span>Permisos</span>
</Link>
</>
)}
</nav>

View File

@@ -0,0 +1,12 @@
import { axiosClient } from '../../../api/axiosClient'
import type { AssignPermisosRequest } from './types'
export async function assignPermisos(
rolCodigo: string,
payload: AssignPermisosRequest,
): Promise<void> {
await axiosClient.put(
`/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`,
payload,
)
}

View File

@@ -0,0 +1,9 @@
import { axiosClient } from '../../../api/axiosClient'
import type { PermisoDto } from './types'
export async function getRolPermisos(rolCodigo: string): Promise<PermisoDto[]> {
const response = await axiosClient.get<PermisoDto[]>(
`/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`,
)
return response.data
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '../../../api/axiosClient'
import type { PermisoDto } from './types'
export async function listPermisos(): Promise<PermisoDto[]> {
const response = await axiosClient.get<PermisoDto[]>('/api/v1/permisos')
return response.data
}

View File

@@ -0,0 +1,11 @@
export interface PermisoDto {
id: number
codigo: string
nombre: string
descripcion: string | null
modulo: string
}
export interface AssignPermisosRequest {
codigos: string[]
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { isAxiosError } from 'axios'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { usePermisos } from '../hooks/usePermisos'
import { useRolPermisos } from '../hooks/useRolPermisos'
import { useAssignPermisos } from '../hooks/useAssignPermisos'
import type { PermisoDto } from '../api/types'
interface RolPermisosEditorProps {
rolCodigo: string
}
function groupByModulo(permisos: PermisoDto[]): Map<string, PermisoDto[]> {
const map = new Map<string, PermisoDto[]>()
for (const p of permisos) {
const modulo = p.codigo.split(':')[0] ?? p.modulo
if (!map.has(modulo)) map.set(modulo, [])
map.get(modulo)!.push(p)
}
return map
}
export function RolPermisosEditor({ rolCodigo }: RolPermisosEditorProps) {
const { data: catalogo, isLoading: loadingCatalogo, isError: errorCatalogo } = usePermisos()
const { data: asignados, isLoading: loadingAsignados, isError: errorAsignados } = useRolPermisos(rolCodigo)
const assignMut = useAssignPermisos()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [saved, setSaved] = useState(false)
// Prefill checkboxes cuando lleguen los permisos asignados al rol
useEffect(() => {
if (asignados) {
setSelected(new Set(asignados.map((p) => p.codigo)))
setSaved(false)
}
}, [asignados])
if (loadingCatalogo || loadingAsignados) {
return <p className="text-sm text-muted-foreground">Cargando permisos...</p>
}
if (errorCatalogo || errorAsignados) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Error al cargar los permisos del rol.</AlertDescription>
</Alert>
)
}
if (!catalogo || catalogo.length === 0) {
return <p className="text-sm text-muted-foreground">No hay permisos registrados en el sistema.</p>
}
const grupos = groupByModulo(catalogo)
function toggle(codigo: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(codigo)) next.delete(codigo)
else next.add(codigo)
return next
})
setSaved(false)
}
function handleSave() {
assignMut.mutate(
{ rolCodigo, payload: { codigos: Array.from(selected) } },
{
onSuccess: () => setSaved(true),
},
)
}
const saveErrMsg = assignMut.error
? isAxiosError(assignMut.error)
? assignMut.error.response?.status === 400
? 'El rol "admin" no puede quedar sin permisos.'
: (assignMut.error.response?.data as { message?: string } | undefined)?.message ??
'No se pudieron guardar los cambios.'
: 'No se pudieron guardar los cambios.'
: null
return (
<div className="space-y-6">
{saveErrMsg && (
<Alert variant="destructive" role="alert">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{saveErrMsg}</AlertDescription>
</Alert>
)}
{saved && (
<Alert role="status">
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>Permisos guardados correctamente.</AlertDescription>
</Alert>
)}
{Array.from(grupos.entries()).map(([modulo, permisos]) => (
<section key={modulo}>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3 capitalize">
{modulo}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{permisos.map((p) => (
<label
key={p.codigo}
title={p.descripcion ?? p.codigo}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
>
<input
type="checkbox"
checked={selected.has(p.codigo)}
onChange={() => toggle(p.codigo)}
className="h-4 w-4 accent-primary shrink-0"
aria-label={p.nombre}
/>
<span className="flex-1 truncate">{p.nombre}</span>
<span className="font-mono text-xs text-muted-foreground/70 hidden lg:block shrink-0">
{p.codigo}
</span>
</label>
))}
</div>
</section>
))}
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={assignMut.isPending}>
{assignMut.isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { assignPermisos } from '../api/assignPermisos'
import { permisosQueryKey } from './usePermisos'
import { rolPermisosQueryKey } from './useRolPermisos'
import type { AssignPermisosRequest } from '../api/types'
interface AssignPermisosVariables {
rolCodigo: string
payload: AssignPermisosRequest
}
export function useAssignPermisos() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ rolCodigo, payload }: AssignPermisosVariables) =>
assignPermisos(rolCodigo, payload),
onSuccess: (_data, { rolCodigo }) => {
void queryClient.invalidateQueries({ queryKey: permisosQueryKey })
void queryClient.invalidateQueries({ queryKey: rolPermisosQueryKey(rolCodigo) })
},
})
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { listPermisos } from '../api/listPermisos'
export const permisosQueryKey = ['permisos'] as const
export function usePermisos() {
return useQuery({
queryKey: permisosQueryKey,
queryFn: listPermisos,
staleTime: 60_000,
})
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { getRolPermisos } from '../api/getRolPermisos'
export function rolPermisosQueryKey(rolCodigo: string) {
return ['permisos', 'rol', rolCodigo] as const
}
export function useRolPermisos(rolCodigo: string | null) {
return useQuery({
queryKey: rolPermisosQueryKey(rolCodigo ?? ''),
queryFn: () => getRolPermisos(rolCodigo!),
enabled: rolCodigo !== null && rolCodigo.length > 0,
staleTime: 30_000,
})
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useRoles } from '../../roles/hooks/useRoles'
import { RolPermisosEditor } from '../components/RolPermisosEditor'
export function RolPermisosPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const [selectedRol, setSelectedRol] = useState<string | null>(null)
const { data: roles, isLoading: loadingRoles } = useRoles()
if (!user || user.rol !== 'admin') {
void navigate('/', { replace: true })
return null
}
const rolesActivos = roles?.filter((r) => r.activo) ?? []
return (
<div className="flex justify-center py-8">
<Card className="w-full max-w-5xl">
<CardHeader className="space-y-1">
<CardTitle className="text-xl">Permisos por rol</CardTitle>
<CardDescription>
Seleccioná un rol para ver y editar sus permisos. Los cambios se aplican inmediatamente al guardar.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Selector de rol */}
<div className="flex flex-col gap-1 max-w-xs">
<label
htmlFor="rol-selector"
className="text-sm font-medium text-foreground"
>
Rol
</label>
{loadingRoles ? (
<p className="text-sm text-muted-foreground">Cargando roles...</p>
) : (
<select
id="rol-selector"
value={selectedRol ?? ''}
onChange={(e) => setSelectedRol(e.target.value || null)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value=""> Seleccioná un rol </option>
{rolesActivos.map((r) => (
<option key={r.codigo} value={r.codigo}>
{r.nombre} ({r.codigo})
</option>
))}
</select>
)}
</div>
{/* Grid de permisos */}
{selectedRol ? (
<RolPermisosEditor rolCodigo={selectedRol} />
) : (
<p className="text-sm text-muted-foreground">
Seleccioná un rol para ver sus permisos.
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { CreateUserPage } from './features/users/pages/CreateUserPage'
import { RolesPage } from './features/roles/pages/RolesPage'
import { NewRolPage } from './features/roles/pages/NewRolPage'
import { EditRolPage } from './features/roles/pages/EditRolPage'
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -88,6 +89,16 @@ export function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/admin/permisos"
element={
<ProtectedRoute>
<ProtectedLayout>
<RolPermisosPage />
</ProtectedLayout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { RolPermisosEditor } from '../../../features/permisos/components/RolPermisosEditor'
const API_URL = 'http://localhost:5000'
const catalogoPermisos = [
{ id: 1, codigo: 'ventas:contado:crear', nombre: 'Crear venta contado', descripcion: 'Permite crear una venta al contado', modulo: 'ventas' },
{ id: 2, codigo: 'ventas:contado:anular', nombre: 'Anular venta contado', descripcion: 'Permite anular una venta al contado', modulo: 'ventas' },
{ id: 3, codigo: 'reportes:ventas:ver', nombre: 'Ver reporte ventas', descripcion: 'Permite ver el reporte de ventas', modulo: 'reportes' },
{ id: 4, codigo: 'admin:usuarios:gestionar', nombre: 'Gestionar usuarios', descripcion: 'Permite crear y editar usuarios', modulo: 'admin' },
]
const rolPermisos = [
catalogoPermisos[0]!, // ventas:contado:crear
catalogoPermisos[2]!, // reportes:ventas:ver
]
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function renderEditor(rolCodigo = 'cajero') {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<RolPermisosEditor rolCodigo={rolCodigo} />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('RolPermisosEditor', () => {
it('renders without crash and shows permission checkboxes grouped by module', async () => {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
)
renderEditor()
// Loading state should appear initially
expect(screen.getByText(/cargando permisos/i)).toBeInTheDocument()
// After fetch resolves, show groups
await waitFor(() => expect(screen.getByText('ventas')).toBeInTheDocument())
expect(screen.getByText('reportes')).toBeInTheDocument()
expect(screen.getByText('admin')).toBeInTheDocument()
// Shows permission names
expect(screen.getByLabelText('Crear venta contado')).toBeInTheDocument()
expect(screen.getByLabelText('Anular venta contado')).toBeInTheDocument()
expect(screen.getByLabelText('Ver reporte ventas')).toBeInTheDocument()
expect(screen.getByLabelText('Gestionar usuarios')).toBeInTheDocument()
})
it('prefills checkboxes for already-assigned permissions', async () => {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
)
renderEditor()
await waitFor(() => expect(screen.getByLabelText('Crear venta contado')).toBeInTheDocument())
const crearCheckbox = screen.getByLabelText('Crear venta contado') as HTMLInputElement
const anularCheckbox = screen.getByLabelText('Anular venta contado') as HTMLInputElement
const reporteCheckbox = screen.getByLabelText('Ver reporte ventas') as HTMLInputElement
const adminCheckbox = screen.getByLabelText('Gestionar usuarios') as HTMLInputElement
// cajero has ventas:contado:crear and reportes:ventas:ver assigned
expect(crearCheckbox.checked).toBe(true)
expect(reporteCheckbox.checked).toBe(true)
// not assigned
expect(anularCheckbox.checked).toBe(false)
expect(adminCheckbox.checked).toBe(false)
})
it('toggles a checkbox on click', async () => {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
)
const u = userEvent.setup()
renderEditor()
await waitFor(() => expect(screen.getByLabelText('Anular venta contado')).toBeInTheDocument())
const anularCheckbox = screen.getByLabelText('Anular venta contado') as HTMLInputElement
expect(anularCheckbox.checked).toBe(false)
// Toggle on
await u.click(anularCheckbox)
expect(anularCheckbox.checked).toBe(true)
// Toggle off
await u.click(anularCheckbox)
expect(anularCheckbox.checked).toBe(false)
})
it('shows success alert after saving permissions', async () => {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
http.put(`${API_URL}/api/v1/roles/cajero/permisos`, () => new HttpResponse(null, { status: 200 })),
)
const u = userEvent.setup()
renderEditor()
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
await u.click(screen.getByText('Guardar cambios'))
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(/guardados correctamente/i),
)
})
it('shows 400 error message when admin would be left without permissions', async () => {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/roles/admin/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.put(`${API_URL}/api/v1/roles/admin/permisos`, () =>
HttpResponse.json({ message: 'El rol admin no puede quedar sin permisos' }, { status: 400 }),
),
)
const u = userEvent.setup()
renderEditor('admin')
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
await u.click(screen.getByText('Guardar cambios'))
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(/admin.*sin permisos/i),
)
})
})