Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <RegisterPage />,
// 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: <HomePage />,
Expand Down
25 changes: 25 additions & 0 deletions src/features/auth/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
46 changes: 46 additions & 0 deletions src/features/auth/hooks/useRegister.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
})
49 changes: 49 additions & 0 deletions src/features/auth/hooks/useRegister.ts
Original file line number Diff line number Diff line change
@@ -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<RegistrationResponse, ApiRequestError, RegistrationRequest>({
mutationFn: async ({ email, password }) => {
const { data } = await client.post<RegistrationResponse>('/auth/register', {
email,
password,
})

return data
},
})
}
9 changes: 8 additions & 1 deletion src/features/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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'
10 changes: 8 additions & 2 deletions src/features/auth/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -128,6 +128,12 @@ export function LoginPage() {
<Button type="submit" variant="contained" size="large" fullWidth loading={isPending}>
{isPending ? authContent.submitting : authContent.submit}
</Button>

{/* La otra mitad de TESIS-51: sin este enlace la pantalla de
solicitud de acceso existe pero no se llega desde ningún lado. */}
<Link component={RouterLink} to="/register" variant="bodyMd" align="center">
{authContent.toRegister}
</Link>
</Stack>
</form>
</AuthCard>
Expand Down
164 changes: 164 additions & 0 deletions src/features/auth/pages/RegisterPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof registerHooks>()),
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<typeof useRegister>)
}

function renderPage() {
return renderWithTheme(
<MemoryRouter initialEntries={['/register']}>
<Routes>
<Route path="/register" element={<RegisterPage />} />
<Route path="/login" element={<p>pantalla de ingreso</p>} />
</Routes>
</MemoryRouter>,
)
}

/** 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<Record<Field, string>> & { terms?: boolean } = {}) {
const { terms = true, ...fields } = overrides
const values: Record<Field, string> = {
email: 'nuevo@empresa.com',
password: 'secreta123',
passwordConfirmation: 'secreta123',
...fields,
}

const labels: Record<Field, string> = {
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',
)
})
})
Loading
Loading