diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx
index 4225bcb..011fa0d 100644
--- a/src/app/router/routes.tsx
+++ b/src/app/router/routes.tsx
@@ -44,6 +44,7 @@ const ProductDetailPage = lazy(() =>
)
const ReportsPage = lazy(() => import('features/reports').then((m) => ({ default: m.ReportsPage })))
const LoginPage = lazy(() => import('features/auth').then((m) => ({ default: m.LoginPage })))
+const RegisterPage = lazy(() => import('features/auth').then((m) => ({ default: m.RegisterPage })))
export interface NavMeta {
label: string
@@ -83,6 +84,13 @@ export const appRoutes: AppRoute[] = [
// Sin `nav`: no pertenece al Sidebar, que sólo existe dentro de la sesión.
layout: 'bare',
},
+ {
+ path: '/register',
+ element: ,
+ // Pública como el login: pedir acceso es lo que hace quien todavía no tiene
+ // cuenta, así que no puede exigir sesión (TESIS-135).
+ layout: 'bare',
+ },
{
path: '/',
element: ,
diff --git a/src/features/auth/content.ts b/src/features/auth/content.ts
index f9edd18..5fe573d 100644
--- a/src/features/auth/content.ts
+++ b/src/features/auth/content.ts
@@ -10,6 +10,26 @@ export const authContent = {
passwordLabel: 'Contraseña',
submit: 'Ingresar',
submitting: 'Ingresando…',
+ toRegister: '¿No tenés cuenta? Crear cuenta',
+ // Copy de S02-Registro. El título de la tarjeta no se toma del diseño: igual
+ // que en el login, ahí va el nombre del tenant (TESIS-121), que es posterior
+ // a la pantalla dibujada.
+ register: {
+ subtitle: 'Solicitá acceso al espacio de operación de tu organización.',
+ passwordLabel: 'Contraseña',
+ passwordConfirmationLabel: 'Confirmar contraseña',
+ terms: 'Acepto los términos del servicio y la política de tratamiento de datos.',
+ submit: 'Crear cuenta',
+ submitting: 'Enviando…',
+ toLogin: '¿Ya tenés cuenta? Ingresar',
+ sent: {
+ title: 'Solicitud enviada',
+ // No afirma que la cuenta se creó: el 202 es el mismo si el email ya
+ // tenía una, y decir «te creamos la cuenta» delataría la diferencia.
+ body: 'Si el correo no tenía una cuenta, tu solicitud quedó pendiente de aprobación. Vas a poder ingresar cuando la habiliten.',
+ backToLogin: 'Volver al inicio de sesión',
+ },
+ },
theme: {
toDark: 'Modo oscuro',
toLight: 'Modo claro',
@@ -26,5 +46,10 @@ export const authContent = {
// El backend frena el login después de 10 intentos en 3 minutos (TESIS-82).
tooManyAttempts: 'Hubo demasiados intentos. Esperá unos minutos y volvé a probar.',
unexpected: 'No pudimos iniciar sesión. Probá de nuevo en unos segundos.',
+ registerFailed: 'No pudimos enviar tu solicitud. Probá de nuevo en unos segundos.',
+ passwordTooShort: 'La contraseña tiene que tener al menos 6 caracteres',
+ passwordConfirmationRequired: 'Repetí la contraseña',
+ passwordMismatch: 'Las contraseñas no coinciden',
+ termsRequired: 'Tenés que aceptar los términos para crear la cuenta',
},
} as const
diff --git a/src/features/auth/hooks/useRegister.test.ts b/src/features/auth/hooks/useRegister.test.ts
new file mode 100644
index 0000000..d3bba5b
--- /dev/null
+++ b/src/features/auth/hooks/useRegister.test.ts
@@ -0,0 +1,46 @@
+import type { ApiRequestError } from 'shared/api'
+import { describe, expect, it } from 'vitest'
+
+import { authContent } from '../content'
+
+import { registerErrorMessage } from './useRegister'
+
+function failedWith(status?: number, message = 'Request failed'): ApiRequestError {
+ const error: ApiRequestError = new Error(message)
+ error.status = status
+ return error
+}
+
+describe('registerErrorMessage', () => {
+ it('says nothing while there is no error', () => {
+ expect(registerErrorMessage(null)).toBeNull()
+ })
+
+ // El 422 es el que explica qué campo está mal; mostrarlo genérico obligaría a
+ // adivinar entre el email, la contraseña y el tenant.
+ it('shows what the server objected to on a 422', () => {
+ const message = 'Password is too short (minimum is 6 characters)'
+
+ expect(registerErrorMessage(failedWith(422, message))).toBe(message)
+ })
+
+ // El mismo freno que el login: 10 intentos en 3 minutos (TESIS-82).
+ it('asks to wait a few minutes on a 429', () => {
+ expect(registerErrorMessage(failedWith(429))).toBe(authContent.errors.tooManyAttempts)
+ })
+
+ it.each([
+ ['a server error', 500],
+ ['a network failure', undefined],
+ ])('does not blame what was typed on %s', (_name, status) => {
+ expect(registerErrorMessage(failedWith(status))).toBe(authContent.errors.registerFailed)
+ })
+
+ // El texto del servidor sólo sale en el 422: un 500 con un stack adentro no
+ // es algo que el usuario tenga que leer.
+ it('does not leak the server message on a 500', () => {
+ expect(registerErrorMessage(failedWith(500, 'PG::Error: relation does not exist'))).toBe(
+ authContent.errors.registerFailed,
+ )
+ })
+})
diff --git a/src/features/auth/hooks/useRegister.ts b/src/features/auth/hooks/useRegister.ts
new file mode 100644
index 0000000..378cdb5
--- /dev/null
+++ b/src/features/auth/hooks/useRegister.ts
@@ -0,0 +1,49 @@
+import { useMutation } from '@tanstack/react-query'
+import { client } from 'shared/api'
+import type { ApiRequestError } from 'shared/api'
+
+import { authContent } from '../content'
+import type { RegistrationRequest, RegistrationResponse } from '../types'
+
+const UNPROCESSABLE = 422
+const TOO_MANY_REQUESTS = 429
+
+/**
+ * Traduce el fallo del alta, con el mismo criterio que `loginErrorMessage`.
+ *
+ * El 422 es el único caso donde se muestra el texto del servidor: es el que
+ * explica qué campo está mal («Password is too short…»), y también el que
+ * responde un slug desconocido o una empresa dada de baja, con un mensaje
+ * deliberadamente vago. Cualquier otro status cae en el genérico: un 500 o una
+ * caída de red no son culpa de lo que el usuario tipeó.
+ */
+export function registerErrorMessage(error: ApiRequestError | null): string | null {
+ if (!error) return null
+ if (error.status === UNPROCESSABLE) return error.message
+ // El mismo freno que el login: 10 intentos en 3 minutos.
+ if (error.status === TOO_MANY_REQUESTS) return authContent.errors.tooManyAttempts
+ return authContent.errors.registerFailed
+}
+
+/**
+ * `POST /auth/register` — pedir acceso al espacio de la empresa (TESIS-135).
+ *
+ * No inicia sesión ni devuelve token: la cuenta nace sin aprobar y no puede
+ * loguearse hasta que la habiliten desde el backoffice. Por eso la mutación no
+ * toca el store de sesión, a diferencia de `useLogin`.
+ *
+ * La empresa la resuelve el backend con el header `X-Tenant-Slug`, que el
+ * cliente agrega en todos los requests; acá no se manda nada de tenant.
+ */
+export function useRegister() {
+ return useMutation({
+ mutationFn: async ({ email, password }) => {
+ const { data } = await client.post('/auth/register', {
+ email,
+ password,
+ })
+
+ return data
+ },
+ })
+}
diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts
index 2232485..3363bb1 100644
--- a/src/features/auth/index.ts
+++ b/src/features/auth/index.ts
@@ -1,3 +1,10 @@
export { LoginPage } from './pages/LoginPage'
+export { RegisterPage } from './pages/RegisterPage'
export { useLogin } from './hooks/useLogin'
-export type { LoginCredentials, LoginResponse } from './types'
+export { useRegister } from './hooks/useRegister'
+export type {
+ LoginCredentials,
+ LoginResponse,
+ RegistrationRequest,
+ RegistrationResponse,
+} from './types'
diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx
index fd7e795..8d35a48 100644
--- a/src/features/auth/pages/LoginPage.tsx
+++ b/src/features/auth/pages/LoginPage.tsx
@@ -2,9 +2,9 @@ import { zodResolver } from '@hookform/resolvers/zod'
import LockOutlinedIcon from '@mui/icons-material/LockOutlined'
import PersonOutlineIcon from '@mui/icons-material/PersonOutline'
import VpnKeyOutlinedIcon from '@mui/icons-material/VpnKeyOutlined'
-import { Alert, Button, InputAdornment, Stack, TextField, Typography } from '@mui/material'
+import { Alert, Button, InputAdornment, Link, Stack, TextField, Typography } from '@mui/material'
import { useForm } from 'react-hook-form'
-import { Navigate, useLocation } from 'react-router-dom'
+import { Link as RouterLink, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore, useTenantName, useTenantStore } from 'shared/store'
import { z } from 'zod'
@@ -128,6 +128,12 @@ export function LoginPage() {
+
+ {/* La otra mitad de TESIS-51: sin este enlace la pantalla de
+ solicitud de acceso existe pero no se llega desde ningún lado. */}
+
+ {authContent.toRegister}
+
diff --git a/src/features/auth/pages/RegisterPage.test.tsx b/src/features/auth/pages/RegisterPage.test.tsx
new file mode 100644
index 0000000..d60e7fe
--- /dev/null
+++ b/src/features/auth/pages/RegisterPage.test.tsx
@@ -0,0 +1,164 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { ApiRequestError } from 'shared/api'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { renderWithTheme } from '../../../test/renderWithTheme'
+import { authContent } from '../content'
+import type * as registerHooks from '../hooks/useRegister'
+import { useRegister } from '../hooks/useRegister'
+
+import { RegisterPage } from './RegisterPage'
+
+vi.mock('../hooks/useRegister', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRegister: vi.fn(),
+}))
+
+const mutate = vi.fn()
+
+interface MutationState {
+ isPending?: boolean
+ isSuccess?: boolean
+ error?: ApiRequestError | null
+}
+
+function mockRegister(state: MutationState = {}) {
+ vi.mocked(useRegister).mockReturnValue({
+ mutate,
+ isPending: false,
+ isSuccess: false,
+ error: null,
+ ...state,
+ } as unknown as ReturnType)
+}
+
+function renderPage() {
+ return renderWithTheme(
+
+
+ } />
+ pantalla de ingreso
} />
+
+ ,
+ )
+}
+
+/** Completa el formulario con un alta válida, salvo lo que se pise, y envía. */
+type Field = 'email' | 'password' | 'passwordConfirmation'
+
+function fillForm(overrides: Partial> & { terms?: boolean } = {}) {
+ const { terms = true, ...fields } = overrides
+ const values: Record = {
+ email: 'nuevo@empresa.com',
+ password: 'secreta123',
+ passwordConfirmation: 'secreta123',
+ ...fields,
+ }
+
+ const labels: Record = {
+ email: authContent.emailLabel,
+ password: authContent.register.passwordLabel,
+ passwordConfirmation: authContent.register.passwordConfirmationLabel,
+ }
+
+ for (const field of Object.keys(labels) as Field[]) {
+ fireEvent.change(screen.getByLabelText(labels[field]), { target: { value: values[field] } })
+ }
+
+ if (terms) fireEvent.click(screen.getByRole('checkbox'))
+
+ fireEvent.click(screen.getByRole('button', { name: authContent.register.submit }))
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockRegister()
+})
+
+describe('RegisterPage', () => {
+ it('sends the email and the password, and nothing else', async () => {
+ renderPage()
+ fillForm()
+
+ // La confirmación es del formulario, no del contrato: la API sólo conoce
+ // email y password.
+ await waitFor(() =>
+ expect(mutate).toHaveBeenCalledWith({ email: 'nuevo@empresa.com', password: 'secreta123' }),
+ )
+ })
+
+ it('does not call the API without accepting the terms', async () => {
+ renderPage()
+ fillForm({ terms: false })
+
+ expect(await screen.findByText(authContent.errors.termsRequired)).toBeInTheDocument()
+ expect(mutate).not.toHaveBeenCalled()
+ })
+
+ it('does not call the API when the passwords do not match', async () => {
+ renderPage()
+ fillForm({ passwordConfirmation: 'otra-cosa' })
+
+ expect(await screen.findByText(authContent.errors.passwordMismatch)).toBeInTheDocument()
+ expect(mutate).not.toHaveBeenCalled()
+ })
+
+ // El mínimo es el de Devise (6): tipear 5 y esperar el viaje al servidor para
+ // que conteste lo mismo es tiempo perdido.
+ it('does not call the API when the password is too short', async () => {
+ renderPage()
+ fillForm({ password: 'corta', passwordConfirmation: 'corta' })
+
+ expect(await screen.findByText(authContent.errors.passwordTooShort)).toBeInTheDocument()
+ expect(mutate).not.toHaveBeenCalled()
+ })
+
+ it('does not call the API with a malformed email', async () => {
+ renderPage()
+ fillForm({ email: 'no-es-un-mail' })
+
+ expect(await screen.findByText(authContent.errors.emailInvalid)).toBeInTheDocument()
+ expect(mutate).not.toHaveBeenCalled()
+ })
+
+ it('reports that the request is pending approval once it went through', () => {
+ mockRegister({ isSuccess: true })
+ renderPage()
+
+ expect(screen.getByText(authContent.register.sent.title)).toBeInTheDocument()
+ expect(screen.getByText(authContent.register.sent.body)).toBeInTheDocument()
+ })
+
+ // El 202 es el mismo si el email ya tenía cuenta, así que la pantalla de
+ // éxito no puede afirmar que la cuenta se creó.
+ it('does not claim the account was created', () => {
+ mockRegister({ isSuccess: true })
+ renderPage()
+
+ expect(
+ screen.queryByRole('button', { name: authContent.register.submit }),
+ ).not.toBeInTheDocument()
+ expect(screen.getByText(authContent.register.sent.body)).toHaveTextContent(
+ /pendiente de aprobación/i,
+ )
+ })
+
+ it('shows what the server objected to', () => {
+ const error: ApiRequestError = new Error('Email is invalid')
+ error.status = 422
+ mockRegister({ error })
+ renderPage()
+
+ expect(screen.getByRole('alert')).toHaveTextContent('Email is invalid')
+ })
+
+ it('offers the way back to the login', () => {
+ renderPage()
+
+ expect(screen.getByRole('link', { name: authContent.register.toLogin })).toHaveAttribute(
+ 'href',
+ '/login',
+ )
+ })
+})
diff --git a/src/features/auth/pages/RegisterPage.tsx b/src/features/auth/pages/RegisterPage.tsx
new file mode 100644
index 0000000..3f2b870
--- /dev/null
+++ b/src/features/auth/pages/RegisterPage.tsx
@@ -0,0 +1,249 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import MarkEmailReadOutlinedIcon from '@mui/icons-material/MarkEmailReadOutlined'
+import PersonAddAltOutlinedIcon from '@mui/icons-material/PersonAddAltOutlined'
+import PersonOutlineIcon from '@mui/icons-material/PersonOutline'
+import VpnKeyOutlinedIcon from '@mui/icons-material/VpnKeyOutlined'
+import {
+ Alert,
+ Button,
+ Checkbox,
+ FormControlLabel,
+ FormHelperText,
+ InputAdornment,
+ Link,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material'
+import { useForm } from 'react-hook-form'
+import { Link as RouterLink, Navigate } from 'react-router-dom'
+import { useAuthStore, useTenantName } from 'shared/store'
+import { z } from 'zod'
+
+import { AuthShell } from '../components/AuthShell'
+import { AuthCard, BrandMark } from '../components/AuthShell.styles'
+import { authContent } from '../content'
+import { registerErrorMessage, useRegister } from '../hooks/useRegister'
+
+const { register: registerCopy, errors: errorCopy } = authContent
+
+// El mínimo de la contraseña es el de Devise (`config.password_length = 6..128`):
+// validarlo acá evita un viaje al servidor para que responda lo mismo. El máximo
+// no se replica — una contraseña de 128 caracteres no es un error que se tipee
+// sin querer, y el 422 del backend lo explica si pasa.
+const MIN_PASSWORD_LENGTH = 6
+
+const schema = z
+ .object({
+ email: z.string().min(1, errorCopy.emailRequired).email(errorCopy.emailInvalid),
+ password: z
+ .string()
+ .min(1, errorCopy.passwordRequired)
+ .min(MIN_PASSWORD_LENGTH, errorCopy.passwordTooShort),
+ passwordConfirmation: z.string().min(1, errorCopy.passwordConfirmationRequired),
+ // El check está en S02. No viaja a la API —no hay dónde registrarlo— pero
+ // frena el envío, que es lo que la pantalla promete.
+ terms: z.literal(true, { message: errorCopy.termsRequired }),
+ })
+ // El campo del error es la confirmación y no la contraseña: el mensaje tiene
+ // que aparecer debajo del campo que hay que corregir.
+ .refine((values) => values.password === values.passwordConfirmation, {
+ path: ['passwordConfirmation'],
+ message: errorCopy.passwordMismatch,
+ })
+
+type RegisterFormData = z.infer
+
+/**
+ * Alta de cuenta — S02-Registro (TESIS-135).
+ *
+ * Crear la cuenta no da sesión: nace sin aprobar y hay que habilitarla desde el
+ * backoffice, como dice la bajada del propio diseño. Por eso la pantalla
+ * termina en un mensaje y no en una redirección al dashboard.
+ *
+ * Dos desvíos respecto de S02, los dos porque el dibujo es anterior al backend
+ * que existe:
+ *
+ * - **Sin campo «Usuario».** La tabla `users` no tiene esa columna: la identidad
+ * es el email, y `POST /auth/register` sólo acepta `email` y `password`. Un
+ * campo que no se guarda en ningún lado es peor que su ausencia.
+ * - **Los términos no enlazan a ninguna parte.** El texto del check es el del
+ * diseño, pero las páginas legales todavía no existen en la app; se sigue el
+ * mismo criterio que en `AuthShell` con el «Centro de ayuda».
+ *
+ * La empresa sale del slug que el cliente manda por header; no hay ningún
+ * campo de tenant en el formulario.
+ */
+export function RegisterPage() {
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
+ const tenantName = useTenantName()
+ const { mutate, isPending, isSuccess, error } = useRegister()
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({ resolver: zodResolver(schema) })
+
+ // Con sesión abierta pedir acceso no tiene sentido, igual que el login.
+ if (isAuthenticated) return
+
+ const requestError = registerErrorMessage(error)
+
+ if (isSuccess) {
+ return (
+
+
+
+
+
+
+
+ {registerCopy.sent.title}
+
+
+ {registerCopy.sent.body}
+
+
+ {registerCopy.sent.backToLogin}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {/* El nombre del tenant manda, como en el login: el portal es el de esa
+ empresa, y es a esa empresa a la que se le pide el acceso. */}
+
+ {tenantName ?? authContent.brand}
+
+
+ {registerCopy.subtitle}
+
+
+
+ {/* `terms` se descarta a propósito: es del formulario, no del contrato. */}
+
+
+
+ )
+}
diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts
index 20b83fa..c0561be 100644
--- a/src/features/auth/types.ts
+++ b/src/features/auth/types.ts
@@ -7,3 +7,24 @@ export interface LoginCredentials {
export interface LoginResponse {
token: string
}
+
+/**
+ * Lo que se manda a `POST /auth/register`. La empresa no viaja en el body: la
+ * resuelve el backend con el header `X-Tenant-Slug`, y un `company_id` acá se
+ * ignora (TESIS-120).
+ */
+export interface RegistrationRequest {
+ email: string
+ password: string
+}
+
+/**
+ * Respuesta de `POST /auth/register`: un 202 con el estado de la solicitud.
+ *
+ * Es la misma se haya creado la cuenta o el email ya tuviera una. La API lo
+ * hace a propósito —si cambiara, el registro diría qué emails existen—, así que
+ * la pantalla tampoco intenta distinguir los dos casos.
+ */
+export interface RegistrationResponse {
+ status: string
+}