({
+ 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 (
+
+
+ )
+}
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()
+ })
+})