diff --git a/src/web/src/features/users/api/deactivateUser.ts b/src/web/src/features/users/api/deactivateUser.ts new file mode 100644 index 0000000..c8fe31a --- /dev/null +++ b/src/web/src/features/users/api/deactivateUser.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail } from '../types' + +export async function deactivateUser(id: number): Promise { + const response = await axiosClient.patch(`/api/v1/users/${id}/deactivate`) + return response.data +} diff --git a/src/web/src/features/users/api/getUser.ts b/src/web/src/features/users/api/getUser.ts new file mode 100644 index 0000000..5d280e9 --- /dev/null +++ b/src/web/src/features/users/api/getUser.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail } from '../types' + +export async function getUser(id: number): Promise { + const response = await axiosClient.get(`/api/v1/users/${id}`) + return response.data +} diff --git a/src/web/src/features/users/api/reactivateUser.ts b/src/web/src/features/users/api/reactivateUser.ts new file mode 100644 index 0000000..7a08ff5 --- /dev/null +++ b/src/web/src/features/users/api/reactivateUser.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail } from '../types' + +export async function reactivateUser(id: number): Promise { + const response = await axiosClient.patch(`/api/v1/users/${id}/reactivate`) + return response.data +} diff --git a/src/web/src/features/users/api/updateUser.ts b/src/web/src/features/users/api/updateUser.ts new file mode 100644 index 0000000..42992e4 --- /dev/null +++ b/src/web/src/features/users/api/updateUser.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail, UpdateUserPayload } from '../types' + +export async function updateUser(id: number, payload: UpdateUserPayload): Promise { + const response = await axiosClient.put(`/api/v1/users/${id}`, { + nombre: payload.nombre, + apellido: payload.apellido, + email: payload.email, + rol: payload.rol, + activo: payload.activo, + }) + return response.data +} diff --git a/src/web/src/features/users/hooks/useDeactivateUser.ts b/src/web/src/features/users/hooks/useDeactivateUser.ts new file mode 100644 index 0000000..5eb1685 --- /dev/null +++ b/src/web/src/features/users/hooks/useDeactivateUser.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateUser } from '../api/deactivateUser' + +export function useDeactivateUser() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (id: number) => deactivateUser(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useReactivateUser.ts b/src/web/src/features/users/hooks/useReactivateUser.ts new file mode 100644 index 0000000..6f2a75e --- /dev/null +++ b/src/web/src/features/users/hooks/useReactivateUser.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateUser } from '../api/reactivateUser' + +export function useReactivateUser() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (id: number) => reactivateUser(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useUpdateUser.ts b/src/web/src/features/users/hooks/useUpdateUser.ts new file mode 100644 index 0000000..411e80b --- /dev/null +++ b/src/web/src/features/users/hooks/useUpdateUser.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateUser } from '../api/updateUser' +import type { UpdateUserPayload } from '../types' + +export function useUpdateUser(userId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (payload: UpdateUserPayload) => updateUser(userId, payload), + onSuccess: () => { + // Invalidate both the detail and the list + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useUser.ts b/src/web/src/features/users/hooks/useUser.ts new file mode 100644 index 0000000..9a1658b --- /dev/null +++ b/src/web/src/features/users/hooks/useUser.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { getUser } from '../api/getUser' + +export const userQueryKey = (id: number) => ['users', id] as const + +export function useUser(id: number) { + return useQuery({ + queryKey: userQueryKey(id), + queryFn: () => getUser(id), + staleTime: 15_000, + enabled: id > 0, + }) +} diff --git a/src/web/src/features/users/pages/UserDetailPage.tsx b/src/web/src/features/users/pages/UserDetailPage.tsx new file mode 100644 index 0000000..2e2cbff --- /dev/null +++ b/src/web/src/features/users/pages/UserDetailPage.tsx @@ -0,0 +1,96 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { useUser } from '../hooks/useUser' +import { useDeactivateUser } from '../hooks/useDeactivateUser' +import { useReactivateUser } from '../hooks/useReactivateUser' + +export function UserDetailPage() { + const { id } = useParams<{ id: string }>() + const userId = Number(id) + const navigate = useNavigate() + + const { data: user, isLoading } = useUser(userId) + const { mutate: deactivate, isPending: deactivating } = useDeactivateUser() + const { mutate: reactivate, isPending: reactivating } = useReactivateUser() + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!user) { + return ( +
+ Usuario no encontrado. +
+ ) + } + + const busy = deactivating || reactivating + + return ( +
+
+

+ {user.nombre} {user.apellido} +

+ +
+ +
+
+ Usuario + {user.username} +
+
+ Email + {user.email ?? '—'} +
+
+ Rol + {user.rol} +
+
+ Estado + {user.activo + ? Activo + : Inactivo + } +
+
+ +
+ + + {user.activo ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/web/src/features/users/pages/UserEditPage.tsx b/src/web/src/features/users/pages/UserEditPage.tsx new file mode 100644 index 0000000..bc4b7c6 --- /dev/null +++ b/src/web/src/features/users/pages/UserEditPage.tsx @@ -0,0 +1,227 @@ +import { useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { useUser } from '../hooks/useUser' +import { useUpdateUser } from '../hooks/useUpdateUser' + +const editSchema = z.object({ + nombre: z.string().min(1, 'El nombre es requerido'), + apellido: z.string().min(1, 'El apellido es requerido'), + email: z.string().email('Email inválido').optional().or(z.literal('')), + rol: z.string().min(1, 'Seleccioná un rol válido'), + activo: z.boolean(), +}) + +type EditFormValues = z.infer + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { title?: string; error?: string; message?: string } + if (data.title === 'last-admin-lockout' || data.error === 'last-admin-lockout') { + return 'No podés cambiar el rol o desactivar al último administrador activo' + } + return data.message ?? data.error ?? 'Error al actualizar el usuario' + } + return 'Error al actualizar el usuario' +} + +export function UserEditPage() { + const { id } = useParams<{ id: string }>() + const userId = Number(id) + const navigate = useNavigate() + + const { data: user, isLoading } = useUser(userId) + const { mutate, isPending, error } = useUpdateUser(userId) + + const form = useForm({ + resolver: zodResolver(editSchema), + defaultValues: { + nombre: '', + apellido: '', + email: '', + rol: '', + activo: true, + }, + }) + + // Prefill form when user data loads + useEffect(() => { + if (user) { + form.reset({ + nombre: user.nombre, + apellido: user.apellido, + email: user.email ?? '', + rol: user.rol, + activo: user.activo, + }) + } + }, [user, form]) + + function handleSubmit(values: EditFormValues) { + mutate( + { + nombre: values.nombre, + apellido: values.apellido, + email: values.email || null, + rol: values.rol, + activo: values.activo, + }, + { + onSuccess: () => { + navigate('/usuarios') + }, + }, + ) + } + + const backendError = resolveBackendError(error) + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!user) { + return ( +
+ Usuario no encontrado. +
+ ) + } + + return ( +
+
+

Editar Usuario

+ +
+ + {/* Username — display only, not editable */} +
+

Usuario

+

{user.username}

+
+ +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Nombre + + + + + + )} + /> + + ( + + Apellido + + + + + + )} + /> + + ( + + Email (opcional) + + + + + + )} + /> + + ( + + Rol + + + + + + )} + /> + + ( + + + + + Activo + + )} + /> + + + + +
+ ) +} diff --git a/src/web/src/tests/features/users/UserEditPage.test.tsx b/src/web/src/tests/features/users/UserEditPage.test.tsx new file mode 100644 index 0000000..b13c183 --- /dev/null +++ b/src/web/src/tests/features/users/UserEditPage.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } 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, Routes, Route } from 'react-router-dom' +import { UserEditPage } from '../../../features/users/pages/UserEditPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +const mockUserDetail = { + id: 5, + username: 'cajero1', + nombre: 'Juan', + apellido: 'Pérez', + email: 'j@x.com', + rol: 'cajero', + activo: true, + mustChangePassword: false, + ultimoLogin: null, + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderEditPage(userId = 5) { + useAuthStore.setState({ user: adminUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + } /> + Users List} /> + + + , + ) +} + +describe('UserEditPage', () => { + it('prefills form with user data', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + expect(screen.getByDisplayValue('Pérez')).toBeInTheDocument() + expect(screen.getByDisplayValue('j@x.com')).toBeInTheDocument() + }) + + it('username field is displayed but not an editable input', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByText('cajero1')).toBeInTheDocument()) + + // Username should NOT be an editable input + const inputs = screen.queryAllByRole('textbox') + const usernameInput = inputs.find((el) => (el as HTMLInputElement).value === 'cajero1') + expect(usernameInput).toBeUndefined() + }) + + it('submit calls PUT with correct payload then navigates to /usuarios', async () => { + let capturedBody: unknown = null + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({ ...mockUserDetail, nombre: 'Pedro' }) + }), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + + // Clear and update nombre + const nombreInput = screen.getByDisplayValue('Juan') + await userEvent.clear(nombreInput) + await userEvent.type(nombreInput, 'Pedro') + + // Submit + await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i })) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/usuarios')) + expect(capturedBody).toMatchObject({ nombre: 'Pedro' }) + }) + + it('shows last-admin-lockout error message on 400', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + http.put(`${API_URL}/api/v1/users/5`, () => + HttpResponse.json( + { title: 'last-admin-lockout', status: 400 }, + { status: 400 }, + ), + ), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i })) + + await waitFor(() => + expect(screen.getByText(/último administrador|last.admin.lockout/i)).toBeInTheDocument(), + ) + + // Should NOT navigate + expect(mockNavigate).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/tests/features/users/getUser.test.ts b/src/web/src/tests/features/users/getUser.test.ts new file mode 100644 index 0000000..d28199b --- /dev/null +++ b/src/web/src/tests/features/users/getUser.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { getUser } from '../../../features/users/api/getUser' + +const API_URL = 'http://localhost:5000' + +const mockDetail = { + id: 5, + username: 'cajero1', + nombre: 'Juan', + apellido: 'Pérez', + email: 'j@x.com', + rol: 'cajero', + activo: true, + mustChangePassword: false, + ultimoLogin: '2026-04-10T10:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('getUser api client', () => { + it('calls GET /api/v1/users/:id and returns UserDetail', async () => { + server.use(http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockDetail))) + const result = await getUser(5) + expect(result.id).toBe(5) + expect(result.username).toBe('cajero1') + expect(result.mustChangePassword).toBe(false) + }) +}) diff --git a/src/web/src/tests/features/users/updateUser.test.ts b/src/web/src/tests/features/users/updateUser.test.ts new file mode 100644 index 0000000..f13b044 --- /dev/null +++ b/src/web/src/tests/features/users/updateUser.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { updateUser } from '../../../features/users/api/updateUser' + +const API_URL = 'http://localhost:5000' + +const mockDetail = { + id: 5, + username: 'cajero1', + nombre: 'Pedro', + apellido: 'Gómez', + email: 'new@x.com', + rol: 'cajero', + activo: true, + mustChangePassword: false, + ultimoLogin: null, + fechaModificacion: '2026-04-15T18:00:00Z', +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('updateUser api client', () => { + it('calls PUT /api/v1/users/:id with payload and returns updated UserDetail', async () => { + let capturedBody: unknown = null + server.use( + http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail) + }), + ) + + const result = await updateUser(5, { + nombre: 'Pedro', + apellido: 'Gómez', + email: 'new@x.com', + rol: 'cajero', + activo: true, + }) + + expect(result.nombre).toBe('Pedro') + expect(capturedBody).toMatchObject({ nombre: 'Pedro', apellido: 'Gómez', email: 'new@x.com' }) + }) +})