From eb11c903a3a948fd1922b5477e4e8a89d3a0692a Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Sun, 27 Sep 2026 22:27:53 -0300 Subject: [PATCH] feat: [TESIS-135] let an operator ask for access from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TESIS-51 shipped the login half of its scope. The register screen was never built, so `POST /auth/register` — live since TESIS-26 — had no caller and the only way in was the backoffice or curl. Creating an account does not start a session: the account is born unapproved and someone enables it from the backoffice, exactly what the subtitle in S02-Registro says. So the screen ends on a message instead of redirecting. Two deviations from S02, both because the drawing predates the backend: - No "Usuario" field. `users` has no such column; identity is the email, and the endpoint takes `email` and `password` and nothing else. - The terms checkbox carries the text from the design but links nowhere, since the legal pages do not exist in the app yet. Same call AuthShell already made with the help center. The 202 is the same whether the account was created or the email already had one — the API does that on purpose so registering cannot be used to discover which emails exist. The success screen keeps that property: it never claims an account was created. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- src/app/router/routes.tsx | 8 + src/features/auth/content.ts | 25 ++ src/features/auth/hooks/useRegister.test.ts | 46 ++++ src/features/auth/hooks/useRegister.ts | 49 ++++ src/features/auth/index.ts | 9 +- src/features/auth/pages/LoginPage.tsx | 10 +- src/features/auth/pages/RegisterPage.test.tsx | 164 ++++++++++++ src/features/auth/pages/RegisterPage.tsx | 249 ++++++++++++++++++ src/features/auth/types.ts | 21 ++ 9 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 src/features/auth/hooks/useRegister.test.ts create mode 100644 src/features/auth/hooks/useRegister.ts create mode 100644 src/features/auth/pages/RegisterPage.test.tsx create mode 100644 src/features/auth/pages/RegisterPage.tsx 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. */} +
mutate({ email, password }))} + noValidate + > + + {requestError ? {requestError} : null} + + + + {authContent.emailLabel} + + + + + ), + }, + }} + {...register('email')} + /> + + + + + {registerCopy.passwordLabel} + + + + + ), + }, + }} + {...register('password')} + /> + + + + + {registerCopy.passwordConfirmationLabel} + + + + + ), + }, + }} + {...register('passwordConfirmation')} + /> + + + + } + label={ + + {registerCopy.terms} + + } + /> + {errors.terms ? {errors.terms.message} : null} + + + + + + {registerCopy.toLogin} + + +
+
+
+ ) +} 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 +}