feat(web): api + hooks permisos overrides [UDT-009]

This commit is contained in:
2026-04-15 21:48:06 -03:00
parent 7d4dc4d2bb
commit c1426b2257
7 changed files with 196 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { UsuarioPermisos } from '../types'
export async function getUserPermisos(id: number): Promise<UsuarioPermisos> {
const response = await axiosClient.get<UsuarioPermisos>(`/api/v1/users/${id}/permisos`)
return response.data
}

View File

@@ -0,0 +1,13 @@
import { axiosClient } from '@/api/axiosClient'
import type { UsuarioPermisos, UpdatePermisosOverridesPayload } from '../types'
export async function updateUserPermisosOverrides(
id: number,
payload: UpdatePermisosOverridesPayload,
): Promise<UsuarioPermisos> {
const response = await axiosClient.put<UsuarioPermisos>(
`/api/v1/users/${id}/permisos/overrides`,
payload,
)
return response.data
}

View File

@@ -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<UsuarioPermisos, Error, UpdatePermisosOverridesPayload>({
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'] })
},
})
}

View File

@@ -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<UsuarioPermisos> {
return useQuery({
queryKey: userPermisosQueryKey(id),
queryFn: () => getUserPermisos(id),
staleTime: 15_000,
enabled: id > 0,
})
}

View File

@@ -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'

View File

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

View File

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