From dd99e5cc69d7481e02005adb365d2eea9165b5cc Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 10:57:11 -0300 Subject: [PATCH] feat(web): UDT-003 formulario de alta de usuarios (admin) Agrega CreateUserPage con UserForm (react-hook-form + Zod), hook useCreateUser (TanStack Query mutation), ruta /users/new protegida y entrada en AppSidebar. Incluye tests Vitest: UserForm (9 casos) y useCreateUser (3 casos). --- src/web/src/components/layout/AppSidebar.tsx | 27 +++ src/web/src/features/users/api/createUser.ts | 25 ++ .../features/users/components/UserForm.tsx | 225 ++++++++++++++++++ .../src/features/users/hooks/useCreateUser.ts | 9 + .../features/users/pages/CreateUserPage.tsx | 42 ++++ src/web/src/router.tsx | 11 + .../tests/features/users/UserForm.test.tsx | 202 ++++++++++++++++ .../features/users/useCreateUser.test.ts | 111 +++++++++ 8 files changed, 652 insertions(+) create mode 100644 src/web/src/features/users/api/createUser.ts create mode 100644 src/web/src/features/users/components/UserForm.tsx create mode 100644 src/web/src/features/users/hooks/useCreateUser.ts create mode 100644 src/web/src/features/users/pages/CreateUserPage.tsx create mode 100644 src/web/src/tests/features/users/UserForm.test.tsx create mode 100644 src/web/src/tests/features/users/useCreateUser.test.ts diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 120d22d..31a153a 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -5,9 +5,11 @@ import { Calculator, Zap, Settings, + UserPlus, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' +import { useAuthStore } from '@/stores/authStore' interface NavItem { label: string @@ -36,6 +38,8 @@ const navItems: NavItem[] = [ export function SidebarNav() { const { pathname } = useLocation() + const user = useAuthStore((s) => s.user) + const isAdmin = user?.rol === 'admin' return ( ) diff --git a/src/web/src/features/users/api/createUser.ts b/src/web/src/features/users/api/createUser.ts new file mode 100644 index 0000000..ad15026 --- /dev/null +++ b/src/web/src/features/users/api/createUser.ts @@ -0,0 +1,25 @@ +import { axiosClient } from '../../../api/axiosClient' + +export interface CreateUserRequest { + username: string + password: string + nombre: string + apellido: string + email?: string + rol: string +} + +export interface CreatedUserDto { + id: number + username: string + nombre: string + apellido: string + email: string | null + rol: string + activo: boolean +} + +export async function createUser(payload: CreateUserRequest): Promise { + const response = await axiosClient.post('/api/v1/users', payload) + return response.data +} diff --git a/src/web/src/features/users/components/UserForm.tsx b/src/web/src/features/users/components/UserForm.tsx new file mode 100644 index 0000000..c5ed7ee --- /dev/null +++ b/src/web/src/features/users/components/UserForm.tsx @@ -0,0 +1,225 @@ +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 { useCreateUser } from '../hooks/useCreateUser' +import type { CreatedUserDto } from '../api/createUser' + +const ROL_OPTIONS = ['admin', 'vendedor', 'tasador', 'consulta'] as const + +const userFormSchema = z.object({ + username: z + .string() + .min(3, 'Mínimo 3 caracteres') + .max(50, 'Máximo 50 caracteres'), + password: z + .string() + .min(8, 'Mínimo 8 caracteres') + .regex(/[a-zA-Z]/, 'Debe contener al menos una letra') + .regex(/[0-9]/, 'Debe contener al menos un dígito'), + 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({ required_error: 'Seleccioná un rol válido' }) + .refine((v): v is (typeof ROL_OPTIONS)[number] => (ROL_OPTIONS as readonly string[]).includes(v), { + message: 'Seleccioná un rol válido', + }), +}) + +type UserFormValues = z.infer + +interface UserFormProps { + onSuccess?: (user: CreatedUserDto) => void +} + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; message?: string } + if (data.error === 'username_taken') { + return data.message ?? 'El usuario ya existe' + } + return data.error ?? 'Error al crear el usuario' + } + return 'Error al crear el usuario' +} + +export function UserForm({ onSuccess }: UserFormProps) { + const { mutate, isPending, error } = useCreateUser() + + const form = useForm({ + resolver: zodResolver(userFormSchema), + defaultValues: { + username: '', + password: '', + nombre: '', + apellido: '', + email: '', + rol: '', + }, + }) + + function handleSubmit(values: UserFormValues) { + mutate( + { + username: values.username, + password: values.password, + nombre: values.nombre, + apellido: values.apellido, + email: values.email || undefined, + rol: values.rol, + }, + { + onSuccess: (data) => { + onSuccess?.(data) + }, + }, + ) + } + + const backendError = resolveBackendError(error) + + return ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Usuario + + + + + + )} + /> + + ( + + Contraseña + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Apellido + + + + + + )} + /> + + ( + + Email (opcional) + + + + + + )} + /> + + ( + + Rol + + + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/users/hooks/useCreateUser.ts b/src/web/src/features/users/hooks/useCreateUser.ts new file mode 100644 index 0000000..b827abc --- /dev/null +++ b/src/web/src/features/users/hooks/useCreateUser.ts @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query' +import { createUser } from '../api/createUser' +import type { CreateUserRequest } from '../api/createUser' + +export function useCreateUser() { + return useMutation({ + mutationFn: (payload: CreateUserRequest) => createUser(payload), + }) +} diff --git a/src/web/src/features/users/pages/CreateUserPage.tsx b/src/web/src/features/users/pages/CreateUserPage.tsx new file mode 100644 index 0000000..a6a05de --- /dev/null +++ b/src/web/src/features/users/pages/CreateUserPage.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' +import { UserForm } from '../components/UserForm' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import type { CreatedUserDto } from '../api/createUser' + +export function CreateUserPage() { + const navigate = useNavigate() + const user = useAuthStore((s) => s.user) + + // Guard: only admins can access this page + if (!user || user.rol !== 'admin') { + void navigate('/', { replace: true }) + return null + } + + function handleSuccess(_created: CreatedUserDto) { + void navigate('/') + } + + return ( +
+ + + Crear Usuario + + Completá los datos para registrar un nuevo usuario en el sistema. + + + + + + +
+ ) +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 9dfe380..1ea7daa 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useAuthStore } from './stores/authStore' import { LoginPage } from './features/auth/pages/LoginPage' +import { CreateUserPage } from './features/users/pages/CreateUserPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -44,6 +45,16 @@ export function AppRoutes() { } /> + + + + + + } + /> } /> ) diff --git a/src/web/src/tests/features/users/UserForm.test.tsx b/src/web/src/tests/features/users/UserForm.test.tsx new file mode 100644 index 0000000..89ab027 --- /dev/null +++ b/src/web/src/tests/features/users/UserForm.test.tsx @@ -0,0 +1,202 @@ +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 } from 'react-router-dom' +import { UserForm } from '../../../features/users/components/UserForm' + +const API_URL = 'http://localhost:5000' + +const mockCreatedUser = { + id: 42, + username: 'jdoe', + nombre: 'Juan', + apellido: 'Doe', + email: null, + rol: 'vendedor', + activo: true, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function renderForm(onSuccess = vi.fn()) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + + return render( + + + + + , + ) +} + +describe('UserForm — Zod validation', () => { + it('shows error when username is too short (< 3 chars)', async () => { + const user = userEvent.setup() + server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) + renderForm() + + await user.type(screen.getByLabelText(/usuario/i), 'ab') + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(screen.getByText(/mínimo 3 caracteres/i)).toBeInTheDocument() + }) + }) + + it('shows error when username exceeds 50 chars', async () => { + const user = userEvent.setup() + server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) + renderForm() + + await user.type(screen.getByLabelText(/usuario/i), 'a'.repeat(51)) + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(screen.getByText(/máximo 50 caracteres/i)).toBeInTheDocument() + }) + }) + + it('shows error when password is too short (< 8 chars)', async () => { + const user = userEvent.setup() + server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) + renderForm() + + await user.type(screen.getByLabelText(/^contraseña$/i), 'Ab1') + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(screen.getByText(/mínimo 8 caracteres/i)).toBeInTheDocument() + }) + }) + + it('shows error when password has no letter', async () => { + const user = userEvent.setup() + server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) + renderForm() + + await user.type(screen.getByLabelText(/^contraseña$/i), '12345678') + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(screen.getByText(/debe contener al menos una letra/i)).toBeInTheDocument() + }) + }) + + it('shows error when password has no digit', async () => { + const user = userEvent.setup() + server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) + renderForm() + + await user.type(screen.getByLabelText(/^contraseña$/i), 'abcdefgh') + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(screen.getByText(/debe contener al menos un dígito/i)).toBeInTheDocument() + }) + }) + + it('shows error when rol is not in whitelist', async () => { + const user = userEvent.setup() + server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) + renderForm() + + // Fill valid fields, leave rol empty (default placeholder) + await user.type(screen.getByLabelText(/usuario/i), 'jdoe123') + await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') + await user.type(screen.getByLabelText(/nombre/i), 'Juan') + await user.type(screen.getByLabelText(/apellido/i), 'Doe') + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(screen.getByText(/seleccioná un rol válido/i)).toBeInTheDocument() + }) + }) + + it('accepts optional empty email', async () => { + server.use( + http.post(`${API_URL}/api/v1/users`, async () => { + return HttpResponse.json(mockCreatedUser, { status: 201 }) + }), + ) + + const onSuccess = vi.fn() + const user = userEvent.setup() + renderForm(onSuccess) + + await user.type(screen.getByLabelText(/usuario/i), 'jdoe123') + await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') + await user.type(screen.getByLabelText(/nombre/i), 'Juan') + await user.type(screen.getByLabelText(/apellido/i), 'Doe') + // Select rol via combobox + await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor') + // email left empty — valid + + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser) + }) + }) +}) + +describe('UserForm — submit and backend error display', () => { + it('calls mutation on valid submit and invokes onSuccess callback', async () => { + server.use( + http.post(`${API_URL}/api/v1/users`, async () => { + return HttpResponse.json(mockCreatedUser, { status: 201 }) + }), + ) + + const onSuccess = vi.fn() + const user = userEvent.setup() + renderForm(onSuccess) + + await user.type(screen.getByLabelText(/usuario/i), 'jdoe123') + await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') + await user.type(screen.getByLabelText(/nombre/i), 'Juan') + await user.type(screen.getByLabelText(/apellido/i), 'Doe') + await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor') + + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser) + }) + }) + + it('shows backend 409 username_taken error in alert', async () => { + server.use( + http.post(`${API_URL}/api/v1/users`, async () => { + return HttpResponse.json( + { error: 'username_taken', message: 'El usuario ya existe' }, + { status: 409 }, + ) + }), + ) + + const user = userEvent.setup() + renderForm() + + await user.type(screen.getByLabelText(/usuario/i), 'existing') + await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') + await user.type(screen.getByLabelText(/nombre/i), 'Juan') + await user.type(screen.getByLabelText(/apellido/i), 'Doe') + await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor') + + await user.click(screen.getByRole('button', { name: /crear usuario/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/usuario ya existe/i) + }) + }) +}) diff --git a/src/web/src/tests/features/users/useCreateUser.test.ts b/src/web/src/tests/features/users/useCreateUser.test.ts new file mode 100644 index 0000000..263889f --- /dev/null +++ b/src/web/src/tests/features/users/useCreateUser.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement } from 'react' +import { useCreateUser } from '../../../features/users/hooks/useCreateUser' + +const API_URL = 'http://localhost:5000' + +const mockCreatedUser = { + id: 42, + username: 'jdoe', + nombre: 'Juan', + apellido: 'Doe', + email: 'jdoe@example.com', + rol: 'vendedor', + activo: true, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) +} + +describe('useCreateUser', () => { + it('mutation succeeds (201) and resolves with created user', async () => { + server.use( + http.post(`${API_URL}/api/v1/users`, async () => { + return HttpResponse.json(mockCreatedUser, { status: 201 }) + }), + ) + + const { result } = renderHook(() => useCreateUser(), { wrapper: makeWrapper() }) + + act(() => { + result.current.mutate({ + username: 'jdoe', + password: 'Secret1234', + nombre: 'Juan', + apellido: 'Doe', + email: 'jdoe@example.com', + rol: 'vendedor', + }) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(mockCreatedUser) + }) + + it('mutation fails with 409 username_taken', async () => { + server.use( + http.post(`${API_URL}/api/v1/users`, async () => { + return HttpResponse.json( + { error: 'username_taken', message: 'El usuario ya existe' }, + { status: 409 }, + ) + }), + ) + + const { result } = renderHook(() => useCreateUser(), { wrapper: makeWrapper() }) + + act(() => { + result.current.mutate({ + username: 'existing', + password: 'Secret1234', + nombre: 'Juan', + apellido: 'Doe', + rol: 'vendedor', + }) + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(result.current.error).toBeTruthy() + }) + + it('mutation fails with 400 validation error', async () => { + server.use( + http.post(`${API_URL}/api/v1/users`, async () => { + return HttpResponse.json( + { errors: { username: ['Username is required'] } }, + { status: 400 }, + ) + }), + ) + + const { result } = renderHook(() => useCreateUser(), { wrapper: makeWrapper() }) + + act(() => { + result.current.mutate({ + username: '', + password: 'Secret1234', + nombre: 'Juan', + apellido: 'Doe', + rol: 'vendedor', + }) + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(result.current.error).toBeTruthy() + }) +})