Public Access
feat(registration): no invite code, request text
This commit is contained in:
1 parent
f4e69e1a94
commit
79b086c525
7 files changed
+55
-42
No files matched your search
@@ -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() {
|
||||
</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}>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -121,15 +121,6 @@ export default function RegistrationPage() {
|
||||
/>
|
||||
</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}>
|
||||
<Input
|
||||
id="displayName"
|
||||
@@ -144,7 +135,7 @@ export default function RegistrationPage() {
|
||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||
{t('REGISTRATION.SUBMIT')}
|
||||
</Button>
|
||||
</form>
|
||||
</form>}
|
||||
|
||||
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user