From 9dbf3e895dc74b776946fa9dfba6055cfa2c7bb7 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 21:49:48 -0300 Subject: [PATCH] feat(web): tabs.tsx + PermisosEditor tri-state [UDT-009] --- src/web/package-lock.json | 87 ++++++++ src/web/package.json | 1 + src/web/src/components/ui/tabs.tsx | 53 +++++ .../users/components/PermisosEditor.tsx | 197 ++++++++++++++++++ .../features/users/PermisosEditor.test.tsx | 197 ++++++++++++++++++ 5 files changed, 535 insertions(+) create mode 100644 src/web/src/components/ui/tabs.tsx create mode 100644 src/web/src/features/users/components/PermisosEditor.tsx create mode 100644 src/web/src/tests/features/users/PermisosEditor.test.tsx diff --git a/src/web/package-lock.json b/src/web/package-lock.json index 3b2ca0e..db4dd82 100644 --- a/src/web/package-lock.json +++ b/src/web/package-lock.json @@ -18,6 +18,7 @@ "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.99.0", "axios": "1.7", "class-variance-authority": "^0.7.1", @@ -2636,6 +2637,92 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/src/web/package.json b/src/web/package.json index 0d8e9df..2f0bde3 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.99.0", "axios": "1.7", "class-variance-authority": "^0.7.1", diff --git a/src/web/src/components/ui/tabs.tsx b/src/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..9f2c71a --- /dev/null +++ b/src/web/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import * as TabsPrimitive from '@radix-ui/react-tabs' + +import { cn } from '@/lib/utils' + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/web/src/features/users/components/PermisosEditor.tsx b/src/web/src/features/users/components/PermisosEditor.tsx new file mode 100644 index 0000000..db5781b --- /dev/null +++ b/src/web/src/features/users/components/PermisosEditor.tsx @@ -0,0 +1,197 @@ +import { useState, useEffect } from 'react' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { useUserPermisos } from '../hooks/useUserPermisos' +import { useUpdateUserPermisosOverrides } from '../hooks/useUpdateUserPermisosOverrides' +import { usePermisos } from '@/features/permisos/hooks/usePermisos' +import type { PermisoOverrideState } from '../types' +import type { PermisoDto } from '@/features/permisos/api/types' + +interface PermisosEditorProps { + userId: number +} + +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 +} + +function resolveErrorMessage(err: unknown): string { + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { title?: string; invalidCodes?: string[]; overlap?: string[] } + if (data.title === 'invalid-permiso-codes') { + const codes = data.invalidCodes?.join(', ') ?? '' + return `Códigos de permiso inválidos: ${codes}` + } + if (data.title === 'grant-deny-overlap') { + const codes = data.overlap?.join(', ') ?? '' + return `Los siguientes permisos están en grant y deny al mismo tiempo: ${codes}` + } + return 'No se pudieron guardar los cambios.' + } + return 'No se pudieron guardar los cambios.' +} + +export function PermisosEditor({ userId }: PermisosEditorProps) { + const { data: permisoData, isLoading: loadingPermisos } = useUserPermisos(userId) + const { data: catalogo, isLoading: loadingCatalogo } = usePermisos() + const mutation = useUpdateUserPermisosOverrides(userId) + + // Map + const [states, setStates] = useState>(new Map()) + const [saveError, setSaveError] = useState(null) + + // Initialize state from loaded data + useEffect(() => { + if (!permisoData) return + const map = new Map() + // Start all known codes as 'heredado' + for (const c of permisoData.rolPermisos) map.set(c, 'heredado') + // Apply grant overrides + for (const c of permisoData.grant) map.set(c, 'concedido') + // Apply deny overrides + for (const c of permisoData.deny) map.set(c, 'denegado') + setStates(map) + setSaveError(null) + }, [permisoData]) + + if (loadingPermisos || loadingCatalogo) { + return

Cargando permisos...

+ } + + if (!permisoData || !catalogo) { + return ( + + + No se pudieron cargar los permisos. + + ) + } + + // Build complete set of all relevant permission codes from catalog + // Filter catalog to only show permisos that appear in rolPermisos, grant, deny, or all catalog + const allCodes = new Set([ + ...permisoData.rolPermisos, + ...permisoData.grant, + ...permisoData.deny, + ]) + + // Use catalog for grouping and names, showing all permisos known plus any from overrides + const relevantPermisos = catalogo.filter( + (p) => allCodes.has(p.codigo) || catalogo.length <= 30, + ) + + const grupos = groupByModulo(relevantPermisos) + + function getState(codigo: string): PermisoOverrideState { + return states.get(codigo) ?? 'heredado' + } + + function setState(codigo: string, state: PermisoOverrideState) { + setSaveError(null) + setStates((prev) => { + const next = new Map(prev) + next.set(codigo, state) + return next + }) + } + + function handleSave() { + const grant: string[] = [] + const deny: string[] = [] + + for (const [codigo, state] of states.entries()) { + if (state === 'concedido') grant.push(codigo) + else if (state === 'denegado') deny.push(codigo) + } + + mutation.mutate( + { grant, deny }, + { + onError: (err) => { + setSaveError(resolveErrorMessage(err)) + }, + onSuccess: () => { + setSaveError(null) + }, + }, + ) + } + + return ( +
+ {saveError && ( + + + {saveError} + + )} + + {Array.from(grupos.entries()).map(([modulo, permisos]) => ( +
+

+ {modulo} +

+
+ {permisos.map((p) => { + const currentState = getState(p.codigo) + return ( +
+
+ {p.nombre} + {p.codigo} +
+
+ {(['heredado', 'concedido', 'denegado'] as PermisoOverrideState[]).map( + (state) => ( + + ), + )} +
+
+ ) + })} +
+
+ ))} + +
+ +
+
+ ) +} diff --git a/src/web/src/tests/features/users/PermisosEditor.test.tsx b/src/web/src/tests/features/users/PermisosEditor.test.tsx new file mode 100644 index 0000000..2c77cac --- /dev/null +++ b/src/web/src/tests/features/users/PermisosEditor.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { render, screen, waitFor, within } 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 { PermisosEditor } from '../../../features/users/components/PermisosEditor' + +const API_URL = 'http://localhost:5000' + +// Catalog of ALL known permissions (from /api/v1/permisos) +const catalogoPermisos = [ + { id: 1, codigo: 'ventas:contado:crear', nombre: 'Crear venta contado', descripcion: null, modulo: 'ventas' }, + { id: 2, codigo: 'ventas:contado:cobrar', nombre: 'Cobrar venta contado', descripcion: null, modulo: 'ventas' }, + { id: 3, codigo: 'textos:editar', nombre: 'Editar textos', descripcion: null, modulo: 'textos' }, +] + +// User permisos — from /api/v1/users/42/permisos +const mockUsuarioPermisos = { + usuarioId: 42, + rol: 'cajero', + rolPermisos: ['ventas:contado:crear', 'ventas:contado:cobrar'], + grant: ['textos:editar'], + deny: ['ventas:contado:cobrar'], + effective: ['ventas:contado:crear', 'textos:editar'], +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function renderEditor(userId = 42) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +function setupHandlers() { + server.use( + http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)), + http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)), + ) +} + +describe('PermisosEditor', () => { + it('calls GET /api/v1/users/:id/permisos on mount', async () => { + let getPermisosCallCount = 0 + server.use( + http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)), + http.get(`${API_URL}/api/v1/users/42/permisos`, () => { + getPermisosCallCount++ + return HttpResponse.json(mockUsuarioPermisos) + }), + ) + + renderEditor(42) + + await waitFor(() => expect(getPermisosCallCount).toBe(1)) + }) + + it('renders permissions grouped by module', async () => { + setupHandlers() + renderEditor() + + await waitFor(() => expect(screen.getByText('ventas')).toBeInTheDocument()) + + expect(screen.getByText('textos')).toBeInTheDocument() + expect(screen.getByText('Crear venta contado')).toBeInTheDocument() + expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument() + expect(screen.getByText('Editar textos')).toBeInTheDocument() + }) + + it('shows Heredado state for permissions in role but not in grant or deny', async () => { + setupHandlers() + renderEditor() + + // ventas:contado:crear is in rolPermisos, not in grant, not in deny + // expect a button/element indicating "Heredado" is active for that permission + await waitFor(() => expect(screen.getByText('Crear venta contado')).toBeInTheDocument()) + + // Should have a "Heredado" indicator active for ventas:contado:crear + // We look for the specific row container and check the selected state + const crearRow = screen.getByTestId('permiso-row-ventas:contado:crear') + expect(within(crearRow).getByRole('button', { name: /heredado/i })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + }) + + it('shows Concedido state for permissions in grant', async () => { + setupHandlers() + renderEditor() + + await waitFor(() => expect(screen.getByText('Editar textos')).toBeInTheDocument()) + + const editarRow = screen.getByTestId('permiso-row-textos:editar') + expect(within(editarRow).getByRole('button', { name: /concedido/i })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + }) + + it('shows Denegado state for permissions in deny', async () => { + setupHandlers() + renderEditor() + + await waitFor(() => expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument()) + + const cobrarRow = screen.getByTestId('permiso-row-ventas:contado:cobrar') + expect(within(cobrarRow).getByRole('button', { name: /denegado/i })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + }) + + it('Guardar button calls PUT with correct { grant, deny } body', async () => { + let capturedBody: unknown = null + server.use( + http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)), + http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)), + http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockUsuarioPermisos) + }), + ) + + const u = userEvent.setup() + renderEditor() + + await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument()) + + await u.click(screen.getByRole('button', { name: /guardar cambios/i })) + + await waitFor(() => expect(capturedBody).not.toBeNull()) + // Initial state: grant=['textos:editar'], deny=['ventas:contado:cobrar'] + expect(capturedBody).toMatchObject({ + grant: expect.arrayContaining(['textos:editar']), + deny: expect.arrayContaining(['ventas:contado:cobrar']), + }) + }) + + it('shows alert on 400 invalid-permiso-codes error', async () => { + server.use( + http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)), + http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)), + http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () => + HttpResponse.json( + { title: 'invalid-permiso-codes', status: 400, invalidCodes: ['fake:codigo'] }, + { status: 400 }, + ), + ), + ) + + const u = userEvent.setup() + renderEditor() + + await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument()) + await u.click(screen.getByRole('button', { name: /guardar cambios/i })) + + await waitFor(() => + expect(screen.getByRole('alert')).toBeInTheDocument(), + ) + }) + + it('shows alert on 400 grant-deny-overlap error', async () => { + server.use( + http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)), + http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)), + http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () => + HttpResponse.json( + { title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] }, + { status: 400 }, + ), + ), + ) + + const u = userEvent.setup() + renderEditor() + + await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument()) + await u.click(screen.getByRole('button', { name: /guardar cambios/i })) + + await waitFor(() => + expect(screen.getByRole('alert')).toBeInTheDocument(), + ) + }) +})