diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 73252da..7c9330e 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -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() { Roles + + + Permisos + )} diff --git a/src/web/src/features/permisos/api/assignPermisos.ts b/src/web/src/features/permisos/api/assignPermisos.ts new file mode 100644 index 0000000..3a81e7a --- /dev/null +++ b/src/web/src/features/permisos/api/assignPermisos.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { AssignPermisosRequest } from './types' + +export async function assignPermisos( + rolCodigo: string, + payload: AssignPermisosRequest, +): Promise { + await axiosClient.put( + `/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`, + payload, + ) +} diff --git a/src/web/src/features/permisos/api/getRolPermisos.ts b/src/web/src/features/permisos/api/getRolPermisos.ts new file mode 100644 index 0000000..2d0aa2d --- /dev/null +++ b/src/web/src/features/permisos/api/getRolPermisos.ts @@ -0,0 +1,9 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { PermisoDto } from './types' + +export async function getRolPermisos(rolCodigo: string): Promise { + const response = await axiosClient.get( + `/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`, + ) + return response.data +} diff --git a/src/web/src/features/permisos/api/listPermisos.ts b/src/web/src/features/permisos/api/listPermisos.ts new file mode 100644 index 0000000..3112882 --- /dev/null +++ b/src/web/src/features/permisos/api/listPermisos.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { PermisoDto } from './types' + +export async function listPermisos(): Promise { + const response = await axiosClient.get('/api/v1/permisos') + return response.data +} diff --git a/src/web/src/features/permisos/api/types.ts b/src/web/src/features/permisos/api/types.ts new file mode 100644 index 0000000..fb5b3a7 --- /dev/null +++ b/src/web/src/features/permisos/api/types.ts @@ -0,0 +1,11 @@ +export interface PermisoDto { + id: number + codigo: string + nombre: string + descripcion: string | null + modulo: string +} + +export interface AssignPermisosRequest { + codigos: string[] +} diff --git a/src/web/src/features/permisos/components/RolPermisosEditor.tsx b/src/web/src/features/permisos/components/RolPermisosEditor.tsx new file mode 100644 index 0000000..a28f4a1 --- /dev/null +++ b/src/web/src/features/permisos/components/RolPermisosEditor.tsx @@ -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 { + const map = new Map() + 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>(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

Cargando permisos...

+ } + + if (errorCatalogo || errorAsignados) { + return ( + + + Error al cargar los permisos del rol. + + ) + } + + if (!catalogo || catalogo.length === 0) { + return

No hay permisos registrados en el sistema.

+ } + + 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 ( +
+ {saveErrMsg && ( + + + {saveErrMsg} + + )} + + {saved && ( + + + Permisos guardados correctamente. + + )} + + {Array.from(grupos.entries()).map(([modulo, permisos]) => ( +
+

+ {modulo} +

+
+ {permisos.map((p) => ( + + ))} +
+
+ ))} + +
+ +
+
+ ) +} diff --git a/src/web/src/features/permisos/hooks/useAssignPermisos.ts b/src/web/src/features/permisos/hooks/useAssignPermisos.ts new file mode 100644 index 0000000..48ad6cc --- /dev/null +++ b/src/web/src/features/permisos/hooks/useAssignPermisos.ts @@ -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) }) + }, + }) +} diff --git a/src/web/src/features/permisos/hooks/usePermisos.ts b/src/web/src/features/permisos/hooks/usePermisos.ts new file mode 100644 index 0000000..d3afcd8 --- /dev/null +++ b/src/web/src/features/permisos/hooks/usePermisos.ts @@ -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, + }) +} diff --git a/src/web/src/features/permisos/hooks/useRolPermisos.ts b/src/web/src/features/permisos/hooks/useRolPermisos.ts new file mode 100644 index 0000000..3fcb986 --- /dev/null +++ b/src/web/src/features/permisos/hooks/useRolPermisos.ts @@ -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, + }) +} diff --git a/src/web/src/features/permisos/pages/RolPermisosPage.tsx b/src/web/src/features/permisos/pages/RolPermisosPage.tsx new file mode 100644 index 0000000..19dd823 --- /dev/null +++ b/src/web/src/features/permisos/pages/RolPermisosPage.tsx @@ -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(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 ( +
+ + + Permisos por rol + + Seleccioná un rol para ver y editar sus permisos. Los cambios se aplican inmediatamente al guardar. + + + + {/* Selector de rol */} +
+ + {loadingRoles ? ( +

Cargando roles...

+ ) : ( + + )} +
+ + {/* Grid de permisos */} + {selectedRol ? ( + + ) : ( +

+ Seleccioná un rol para ver sus permisos. +

+ )} +
+
+
+ ) +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index d43145d..5f2c352 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -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() { } /> + + + + + + } + /> } /> ) diff --git a/src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx b/src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx new file mode 100644 index 0000000..55e1d0d --- /dev/null +++ b/src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx @@ -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( + + + + + , + ) +} + +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), + ) + }) +})