From 8dcd03f219faa911b552678a8c28897ed038ec01 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 25 Sep 2026 09:06:30 +0200 Subject: [PATCH] feat(registration): show the wait time after too many attempts --- public/i18n/de.json | 3 +- public/i18n/en.json | 3 +- src/pages/RegistrationPage.jsx | 6 +++- src/pages/RegistrationPage.test.jsx | 54 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/pages/RegistrationPage.test.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index 7f171c5..e441a05 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -32,7 +32,8 @@ "ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.", "ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft.", "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." }, "SETTINGS": { "TITLE": "Einstellungen", diff --git a/public/i18n/en.json b/public/i18n/en.json index 748ae48..20d8cf7 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -32,7 +32,8 @@ "ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.", "ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.", "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." }, "SETTINGS": { "TITLE": "Settings", diff --git a/src/pages/RegistrationPage.jsx b/src/pages/RegistrationPage.jsx index 20ac609..8e7c3a9 100644 --- a/src/pages/RegistrationPage.jsx +++ b/src/pages/RegistrationPage.jsx @@ -18,6 +18,7 @@ const BACKEND_ERROR_KEY = { INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE', STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE', + TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', }; export default function RegistrationPage() { @@ -65,8 +66,11 @@ export default function RegistrationPage() { const data = await response.json().catch(() => ({})); const mappedKey = BACKEND_ERROR_KEY[data.code]; + const options = response.status === 429 + ? {minutes: Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60)} + : undefined; setFeedback({ - message: mappedKey ? t(mappedKey) : data.message || t('REGISTRATION.ERROR_GENERIC'), + message: mappedKey ? t(mappedKey, options) : data.message || t('REGISTRATION.ERROR_GENERIC'), isError: true, }); } catch (err) { diff --git a/src/pages/RegistrationPage.test.jsx b/src/pages/RegistrationPage.test.jsx new file mode 100644 index 0000000..e2d3b32 --- /dev/null +++ b/src/pages/RegistrationPage.test.jsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +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'; + +// Own mock: the shared one drops the {{minutes}} value this test checks. +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, options) => (options?.minutes === undefined ? key : `${key}:${options.minutes}`), + i18n: {language: 'de'}, + }), +})); + +function errorResponse(status, code, headers = {}) { + return { + ok: false, + status, + headers: new Headers(headers), + json: async () => ({code}), + }; +} + +function submitValidForm() { + render(); + 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.change(screen.getByLabelText('REGISTRATION.STEAM_ID'), {target: {value: '76561198000000001'}}); + fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'})); +} + +describe('RegistrationPage', () => { + afterEach(() => { + cleanup(); + }); + + it('shows the wait time when the IP is blocked', async () => { + globalThis.fetch = vi.fn(async () => errorResponse(429, 'TOO_MANY_ATTEMPTS', {'Retry-After': '900'})); + + submitValidForm(); + + expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy(); + }); + + it('maps a known backend error to its message', async () => { + globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE')); + + submitValidForm(); + + expect(await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE')).toBeTruthy(); + }); +});