diff --git a/README.md b/README.md index c8fe2fb..75b5d70 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ To Be Played is split into three repositories: - **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark mode stays per device. The registration sends the browser language (`de` for a German browser, else `en`): Auth writes its mails in it and saves it as the user's language. +- **Registration** (`/register`): no invite code; the page says that an admin checks each new account and stays on + "registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while 20 wait. - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night; a repaired cover comes back as `imageUrl`. diff --git a/public/i18n/de.json b/public/i18n/de.json index 5db29e5..ff71564 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -24,16 +24,16 @@ "EMAIL": "Email", "PASSWORD": "Passwort", "PASSWORD_CONFIRMATION": "Passwort bestätigen", - "INVITE_CODE": "Einladungscode", "SUBMIT": "Konto erstellen", - "SUCCESS": "Konto erstellt. Weiterleitung zum Login …", + "SUCCESS": "Registrierung gesendet. Du bekommst eine Mail, sobald der Admin entschieden hat.", "HAVE_ACCOUNT": "Bereits ein Konto?", "ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.", - "ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.", "DISPLAY_NAME": "Anzeigename (optional)", "ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.", - "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen." + "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen.", + "INTRO": "Ein Admin prüft jedes neue Konto. Du bekommst eine Mail, sobald über deins entschieden ist.", + "ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal." }, "SETTINGS": { "PROFILE_STYLE": "Aussehen des Profils", @@ -138,7 +138,6 @@ "PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.", "PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.", "PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.", - "INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)." }, "INFO": { diff --git a/public/i18n/en.json b/public/i18n/en.json index 282e9a4..daf91be 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -24,16 +24,16 @@ "EMAIL": "Email", "PASSWORD": "Password", "PASSWORD_CONFIRMATION": "Confirm password", - "INVITE_CODE": "Invite code", "SUBMIT": "Create account", - "SUCCESS": "Account created. Redirecting to login …", + "SUCCESS": "Registration sent. You get a mail as soon as the admin has decided.", "HAVE_ACCOUNT": "Already have an account?", "ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_EMAIL_IN_USE": "This email address is already registered.", - "ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.", "DISPLAY_NAME": "Display name (optional)", "ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.", - "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes." + "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.", + "INTRO": "An admin checks each new account. You get a mail as soon as yours is decided.", + "ERROR_PENDING_LIMIT": "Too many registrations wait for the admin right now. Please try again later." }, "SETTINGS": { "PROFILE_STYLE": "Profile look", @@ -138,7 +138,6 @@ "PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.", "PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.", "PASSWORD_MISMATCH": "Passwords do not match.", - "INVITE_CODE_REQUIRED": "Invite code is required.", "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)." }, "INFO": { diff --git a/src/pages/RegistrationPage.jsx b/src/pages/RegistrationPage.jsx index 8340be1..f280f2a 100644 --- a/src/pages/RegistrationPage.jsx +++ b/src/pages/RegistrationPage.jsx @@ -1,5 +1,5 @@ import {useState} from 'react'; -import {Link, useNavigate} from 'react-router-dom'; +import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; @@ -17,15 +17,16 @@ import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js'; const BACKEND_ERROR_KEY = { EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', - INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE', + PENDING_LIMIT_REACHED: 'REGISTRATION.ERROR_PENDING_LIMIT', DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE', TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', }; export default function RegistrationPage() { const {t} = useTranslation(); - const navigate = useNavigate(); const [feedback, setFeedback] = useState({message: '', isError: false}); + // The request waits for the admin; a second one would only be refused. + const [requested, setRequested] = useState(false); const { register, @@ -37,7 +38,6 @@ export default function RegistrationPage() { email: '', password: '', passwordConfirmation: '', - inviteCode: '', displayName: '', }, }); @@ -52,7 +52,6 @@ export default function RegistrationPage() { body: JSON.stringify({ email: values.email, password: values.password, - inviteCode: values.inviteCode, displayName: values.displayName, // Auth's mails use it: a German browser gets German, every other one English. language: navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en', @@ -61,7 +60,7 @@ export default function RegistrationPage() { if (response.ok) { setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false}); - setTimeout(() => navigate('/login'), 800); + setRequested(true); return; } @@ -90,7 +89,8 @@ export default function RegistrationPage() { } > -
+

{t('REGISTRATION.INTRO')}

+ {!requested && - - - - } {t('REGISTRATION.SUBMIT')} - + } diff --git a/src/pages/RegistrationPage.test.jsx b/src/pages/RegistrationPage.test.jsx index 9cf21e2..bd9e1bd 100644 --- a/src/pages/RegistrationPage.test.jsx +++ b/src/pages/RegistrationPage.test.jsx @@ -3,7 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import RegistrationPage from './RegistrationPage.jsx'; -import {fetchRoutes} from '../test/fetchRoutes.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; // Own mock: the shared one drops the {{minutes}} value this test checks. vi.mock('react-i18next', () => ({ @@ -23,7 +23,6 @@ function submitValidForm() { fireEvent.change(screen.getByLabelText('REGISTRATION.EMAIL'), {target: {value: 'a@b.de'}}); fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}}); fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: 'secret123'}}); - fireEvent.change(screen.getByLabelText('REGISTRATION.INVITE_CODE'), {target: {value: 'code'}}); fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'})); } @@ -42,11 +41,11 @@ describe('RegistrationPage', () => { }); it('asks for no Steam ID and sends none', async () => { - answerRegistration(400, 'INVALID_INVITATION_CODE'); + answerRegistration(409, 'EMAIL_ALREADY_IN_USE'); submitValidForm(); - await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE'); + await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE'); expect(screen.queryByLabelText('REGISTRATION.STEAM_ID')).toBeNull(); expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('steamId'); }); @@ -54,20 +53,48 @@ describe('RegistrationPage', () => { it.each([['de-DE', 'de'], ['de', 'de'], ['en-US', 'en'], ['fr-FR', 'en'], [undefined, 'en']])( 'sends the browser language %s as %s, for the mails', async (browser, sent) => { vi.spyOn(navigator, 'language', 'get').mockReturnValue(browser); - answerRegistration(400, 'INVALID_INVITATION_CODE'); + answerRegistration(409, 'EMAIL_ALREADY_IN_USE'); submitValidForm(); - await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE'); + await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE'); expect(JSON.parse(fetch.mock.calls[0][1].body).language).toBe(sent); }); it('maps a known backend error to its message', async () => { - answerRegistration(400, 'INVALID_INVITATION_CODE'); + answerRegistration(409, 'EMAIL_ALREADY_IN_USE'); submitValidForm(); - expect(await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE')).toBeTruthy(); + expect(await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE')).toBeTruthy(); + }); + + it('asks for no invite code and sends none', async () => { + answerRegistration(409, 'EMAIL_ALREADY_IN_USE'); + + submitValidForm(); + + await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE'); + expect(screen.queryByLabelText('REGISTRATION.INVITE_CODE')).toBeNull(); + expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('inviteCode'); + }); + + it('says the request waits for the admin and offers no second send', async () => { + fetchRoutes({'POST /api/registration/new': json(200, {userId: 5})}); + + submitValidForm(); + + expect(await screen.findByText('REGISTRATION.SUCCESS')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'REGISTRATION.SUBMIT'})).toBeNull(); + expect(screen.getByText('REGISTRATION.INTRO')).toBeTruthy(); + }); + + it('says so when too many registrations wait', async () => { + answerRegistration(409, 'PENDING_LIMIT_REACHED'); + + submitValidForm(); + + expect(await screen.findByText('REGISTRATION.ERROR_PENDING_LIMIT')).toBeTruthy(); }); it('shows its own text, not the English one of the server, for a refused input', async () => { diff --git a/src/utils/validation.js b/src/utils/validation.js index dbb000f..7447563 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -13,10 +13,6 @@ const passwordField = z .min(8, 'VALIDATION.PASSWORD_TOO_SHORT') .refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG'); -const inviteCodeField = z - .string() - .min(1, 'VALIDATION.INVITE_CODE_REQUIRED'); - // Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends. // An empty field means "no display name" and is sent as null. const displayNameField = z @@ -33,7 +29,6 @@ export const registrationSchema = z email: emailField, password: passwordField, passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'), - inviteCode: inviteCodeField, displayName: displayNameField, }) .refine((d) => d.password === d.passwordConfirmation, { diff --git a/src/utils/validation.test.js b/src/utils/validation.test.js index 74fe443..fe4cea8 100644 --- a/src/utils/validation.test.js +++ b/src/utils/validation.test.js @@ -7,7 +7,7 @@ describe('Steam ID', () => { it('is no form field any more: it comes only from "Sign in through Steam"', () => { expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId'); expect(registrationSchema.parse({ - email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', inviteCode: 'code', + email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', steamId: '76561198005679788', })).not.toHaveProperty('steamId'); }); @@ -34,7 +34,7 @@ describe('registration password', () => { it('rejects a password over 72 UTF-8 bytes, as Auth does', () => { const password = 'é'.repeat(37); expect(registrationSchema.safeParse({ - email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code', + email: 'a@b.de', password, passwordConfirmation: password, }).success).toBe(false); }); });