feat(registration): no invite code, request text
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 22s

This commit is contained in:
lobo committed 2026-10-01 09:18:47 +02:00
1 parent f4e69e1a94
commit 79b086c525
7 files changed
+55 -42

No files matched your search

+2
View File
@@ -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 - **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 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. 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 - **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; 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`. a repaired cover comes back as `imageUrl`.
+4 -5
View File
@@ -24,16 +24,16 @@
"EMAIL": "Email", "EMAIL": "Email",
"PASSWORD": "Passwort", "PASSWORD": "Passwort",
"PASSWORD_CONFIRMATION": "Passwort bestätigen", "PASSWORD_CONFIRMATION": "Passwort bestätigen",
"INVITE_CODE": "Einladungscode",
"SUBMIT": "Konto erstellen", "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?", "HAVE_ACCOUNT": "Bereits ein Konto?",
"ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.",
"ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.", "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)", "DISPLAY_NAME": "Anzeigename (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.", "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": { "SETTINGS": {
"PROFILE_STYLE": "Aussehen des Profils", "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_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.", "PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.", "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)." "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
}, },
"INFO": { "INFO": {
+4 -5
View File
@@ -24,16 +24,16 @@
"EMAIL": "Email", "EMAIL": "Email",
"PASSWORD": "Password", "PASSWORD": "Password",
"PASSWORD_CONFIRMATION": "Confirm password", "PASSWORD_CONFIRMATION": "Confirm password",
"INVITE_CODE": "Invite code",
"SUBMIT": "Create account", "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?", "HAVE_ACCOUNT": "Already have an account?",
"ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_GENERIC": "Registration failed. Please try again.",
"ERROR_EMAIL_IN_USE": "This email address is already registered.", "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)", "DISPLAY_NAME": "Display name (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.", "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": { "SETTINGS": {
"PROFILE_STYLE": "Profile look", "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_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.", "PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"PASSWORD_MISMATCH": "Passwords do not match.", "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)." "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
}, },
"INFO": { "INFO": {
+8 -17
View File
@@ -1,5 +1,5 @@
import {useState} from 'react'; import {useState} from 'react';
import {Link, useNavigate} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form'; import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
@@ -17,15 +17,16 @@ import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', 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', DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS',
}; };
export default function RegistrationPage() { export default function RegistrationPage() {
const {t} = useTranslation(); const {t} = useTranslation();
const navigate = useNavigate();
const [feedback, setFeedback] = useState({message: '', isError: false}); 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 { const {
register, register,
@@ -37,7 +38,6 @@ export default function RegistrationPage() {
email: '', email: '',
password: '', password: '',
passwordConfirmation: '', passwordConfirmation: '',
inviteCode: '',
displayName: '', displayName: '',
}, },
}); });
@@ -52,7 +52,6 @@ export default function RegistrationPage() {
body: JSON.stringify({ body: JSON.stringify({
email: values.email, email: values.email,
password: values.password, password: values.password,
inviteCode: values.inviteCode,
displayName: values.displayName, displayName: values.displayName,
// Auth's mails use it: a German browser gets German, every other one English. // Auth's mails use it: a German browser gets German, every other one English.
language: navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en', language: navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en',
@@ -61,7 +60,7 @@ export default function RegistrationPage() {
if (response.ok) { if (response.ok) {
setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false}); setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false});
setTimeout(() => navigate('/login'), 800); setRequested(true);
return; return;
} }
@@ -90,7 +89,8 @@ export default function RegistrationPage() {
</Link> </Link>
</>} </>}
> >
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate> <p className="mb-4 text-sm text-muted-foreground">{t('REGISTRATION.INTRO')}</p>
{!requested && <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<FormField id="email" label={t('REGISTRATION.EMAIL')} error={errors.email} t={t}> <FormField id="email" label={t('REGISTRATION.EMAIL')} error={errors.email} t={t}>
<Input <Input
id="email" id="email"
@@ -121,15 +121,6 @@ export default function RegistrationPage() {
/> />
</FormField> </FormField>
<FormField id="inviteCode" label={t('REGISTRATION.INVITE_CODE')} error={errors.inviteCode} t={t}>
<Input
id="inviteCode"
type="text"
aria-invalid={!!errors.inviteCode}
{...register('inviteCode')}
/>
</FormField>
<FormField id="displayName" label={t('REGISTRATION.DISPLAY_NAME')} error={errors.displayName} t={t}> <FormField id="displayName" label={t('REGISTRATION.DISPLAY_NAME')} error={errors.displayName} t={t}>
<Input <Input
id="displayName" id="displayName"
@@ -144,7 +135,7 @@ export default function RegistrationPage() {
{isSubmitting && <Loader2 className="animate-spin"/>} {isSubmitting && <Loader2 className="animate-spin"/>}
{t('REGISTRATION.SUBMIT')} {t('REGISTRATION.SUBMIT')}
</Button> </Button>
</form> </form>}
<FeedbackAlert message={feedback.message} isError={feedback.isError}/> <FeedbackAlert message={feedback.message} isError={feedback.isError}/>
</AuthLayout> </AuthLayout>
+35 -8
View File
@@ -3,7 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import RegistrationPage from './RegistrationPage.jsx'; 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. // Own mock: the shared one drops the {{minutes}} value this test checks.
vi.mock('react-i18next', () => ({ 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.EMAIL'), {target: {value: 'a@b.de'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}}); fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {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'})); fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'}));
} }
@@ -42,11 +41,11 @@ describe('RegistrationPage', () => {
}); });
it('asks for no Steam ID and sends none', async () => { it('asks for no Steam ID and sends none', async () => {
answerRegistration(400, 'INVALID_INVITATION_CODE'); answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm(); 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(screen.queryByLabelText('REGISTRATION.STEAM_ID')).toBeNull();
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('steamId'); 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']])( 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) => { 'sends the browser language %s as %s, for the mails', async (browser, sent) => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue(browser); vi.spyOn(navigator, 'language', 'get').mockReturnValue(browser);
answerRegistration(400, 'INVALID_INVITATION_CODE'); answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm(); 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); expect(JSON.parse(fetch.mock.calls[0][1].body).language).toBe(sent);
}); });
it('maps a known backend error to its message', async () => { it('maps a known backend error to its message', async () => {
answerRegistration(400, 'INVALID_INVITATION_CODE'); answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm(); 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 () => { it('shows its own text, not the English one of the server, for a refused input', async () => {
-5
View File
@@ -13,10 +13,6 @@ const passwordField = z
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT') .min(8, 'VALIDATION.PASSWORD_TOO_SHORT')
.refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG'); .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. // 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. // An empty field means "no display name" and is sent as null.
const displayNameField = z const displayNameField = z
@@ -33,7 +29,6 @@ export const registrationSchema = z
email: emailField, email: emailField,
password: passwordField, password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'), passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
inviteCode: inviteCodeField,
displayName: displayNameField, displayName: displayNameField,
}) })
.refine((d) => d.password === d.passwordConfirmation, { .refine((d) => d.password === d.passwordConfirmation, {
+2 -2
View File
@@ -7,7 +7,7 @@ describe('Steam ID', () => {
it('is no form field any more: it comes only from "Sign in through Steam"', () => { 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(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId');
expect(registrationSchema.parse({ expect(registrationSchema.parse({
email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', inviteCode: 'code', email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1',
steamId: '76561198005679788', steamId: '76561198005679788',
})).not.toHaveProperty('steamId'); })).not.toHaveProperty('steamId');
}); });
@@ -34,7 +34,7 @@ describe('registration password', () => {
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => { it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
const password = 'é'.repeat(37); const password = 'é'.repeat(37);
expect(registrationSchema.safeParse({ expect(registrationSchema.safeParse({
email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code', email: 'a@b.de', password, passwordConfirmation: password,
}).success).toBe(false); }).success).toBe(false);
}); });
}); });