diff --git a/src/web/src/features/users/api/getUserPermisos.ts b/src/web/src/features/users/api/getUserPermisos.ts new file mode 100644 index 0000000..5862100 --- /dev/null +++ b/src/web/src/features/users/api/getUserPermisos.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UsuarioPermisos } from '../types' + +export async function getUserPermisos(id: number): Promise { + const response = await axiosClient.get(`/api/v1/users/${id}/permisos`) + return response.data +} diff --git a/src/web/src/features/users/api/updateUserPermisosOverrides.ts b/src/web/src/features/users/api/updateUserPermisosOverrides.ts new file mode 100644 index 0000000..3eaa7b8 --- /dev/null +++ b/src/web/src/features/users/api/updateUserPermisosOverrides.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UsuarioPermisos, UpdatePermisosOverridesPayload } from '../types' + +export async function updateUserPermisosOverrides( + id: number, + payload: UpdatePermisosOverridesPayload, +): Promise { + const response = await axiosClient.put( + `/api/v1/users/${id}/permisos/overrides`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/users/hooks/useUpdateUserPermisosOverrides.ts b/src/web/src/features/users/hooks/useUpdateUserPermisosOverrides.ts new file mode 100644 index 0000000..654ebed --- /dev/null +++ b/src/web/src/features/users/hooks/useUpdateUserPermisosOverrides.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateUserPermisosOverrides } from '../api/updateUserPermisosOverrides' +import type { UpdatePermisosOverridesPayload, UsuarioPermisos } from '../types' +import { userPermisosQueryKey } from './useUserPermisos' +import { userQueryKey } from './useUser' + +export function useUpdateUserPermisosOverrides(userId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (payload) => updateUserPermisosOverrides(userId, payload), + onSuccess: () => { + // Invalidate the specific user's permisos query + queryClient.invalidateQueries({ queryKey: userPermisosQueryKey(userId) }) + // Invalidate the user detail query (in case effective permisos affect UI elsewhere) + queryClient.invalidateQueries({ queryKey: userQueryKey(userId) }) + // Invalidate the users list + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useUserPermisos.ts b/src/web/src/features/users/hooks/useUserPermisos.ts new file mode 100644 index 0000000..67decbc --- /dev/null +++ b/src/web/src/features/users/hooks/useUserPermisos.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query' +import { getUserPermisos } from '../api/getUserPermisos' +import type { UsuarioPermisos } from '../types' +import type { UseQueryResult } from '@tanstack/react-query' + +export const userPermisosQueryKey = (id: number) => ['users', id, 'permisos'] as const + +export function useUserPermisos(id: number): UseQueryResult { + return useQuery({ + queryKey: userPermisosQueryKey(id), + queryFn: () => getUserPermisos(id), + staleTime: 15_000, + enabled: id > 0, + }) +} diff --git a/src/web/src/features/users/types.ts b/src/web/src/features/users/types.ts index b2a1732..62b38e4 100644 --- a/src/web/src/features/users/types.ts +++ b/src/web/src/features/users/types.ts @@ -46,3 +46,21 @@ export interface UpdateUserPayload { rol: string activo: boolean } + +// UDT-009 — Permisos overrides per-user types + +export interface UsuarioPermisos { + usuarioId: number + rol: string + rolPermisos: string[] + grant: string[] + deny: string[] + effective: string[] +} + +export interface UpdatePermisosOverridesPayload { + grant: string[] + deny: string[] +} + +export type PermisoOverrideState = 'heredado' | 'concedido' | 'denegado' diff --git a/src/web/src/tests/features/users/getUserPermisos.test.ts b/src/web/src/tests/features/users/getUserPermisos.test.ts new file mode 100644 index 0000000..4e80a01 --- /dev/null +++ b/src/web/src/tests/features/users/getUserPermisos.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { getUserPermisos } from '../../../features/users/api/getUserPermisos' + +const API_URL = 'http://localhost:5000' + +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()) + +describe('getUserPermisos api client', () => { + it('calls GET /api/v1/users/:id/permisos and returns UsuarioPermisos shape', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/42/permisos`, () => + HttpResponse.json(mockUsuarioPermisos), + ), + ) + + const result = await getUserPermisos(42) + + expect(result.usuarioId).toBe(42) + expect(result.rol).toBe('cajero') + expect(result.rolPermisos).toEqual(['ventas:contado:crear', 'ventas:contado:cobrar']) + expect(result.grant).toEqual(['textos:editar']) + expect(result.deny).toEqual(['ventas:contado:cobrar']) + expect(result.effective).toEqual(['ventas:contado:crear', 'textos:editar']) + }) + + it('rejects with error on 404', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/9999/permisos`, () => + HttpResponse.json({ title: 'Not Found', status: 404 }, { status: 404 }), + ), + ) + + await expect(getUserPermisos(9999)).rejects.toThrow() + }) +}) diff --git a/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts b/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts new file mode 100644 index 0000000..b46707f --- /dev/null +++ b/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { updateUserPermisosOverrides } from '../../../features/users/api/updateUserPermisosOverrides' + +const API_URL = 'http://localhost:5000' + +const mockResponse = { + usuarioId: 42, + rol: 'cajero', + rolPermisos: ['ventas:contado:crear'], + grant: ['textos:editar'], + deny: [], + effective: ['ventas:contado:crear', 'textos:editar'], +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('updateUserPermisosOverrides api client', () => { + it('calls PUT /api/v1/users/:id/permisos/overrides with correct body and returns UsuarioPermisos', async () => { + let capturedBody: unknown = null + server.use( + http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockResponse) + }), + ) + + const result = await updateUserPermisosOverrides(42, { + grant: ['textos:editar'], + deny: [], + }) + + expect(result.grant).toEqual(['textos:editar']) + expect(result.effective).toContain('textos:editar') + expect(capturedBody).toMatchObject({ grant: ['textos:editar'], deny: [] }) + }) + + it('rejects on 400 invalid-permiso-codes', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () => + HttpResponse.json( + { title: 'invalid-permiso-codes', status: 400, invalidCodes: ['modulo:fake:accion'] }, + { status: 400 }, + ), + ), + ) + + await expect( + updateUserPermisosOverrides(42, { grant: ['modulo:fake:accion'], deny: [] }), + ).rejects.toThrow() + }) + + it('rejects on 400 grant-deny-overlap', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () => + HttpResponse.json( + { title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] }, + { status: 400 }, + ), + ), + ) + + await expect( + updateUserPermisosOverrides(42, { grant: ['textos:editar'], deny: ['textos:editar'] }), + ).rejects.toThrow() + }) +})