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 +}