feat(registration): add email confirmation UI
Build, Test & Push Frontend / quality-check (push) Successful in 26s
Build, Test & Push Frontend / docker (push) Successful in 31s

New ConfirmEmailPage for users to verify their email and set password.
Resend form on registration and error pages. Registration form no
longer asks for a password. Confirmation requires a button click. The
admin list marks unconfirmed requests and offers only delete for them.
This commit is contained in:
lobo committed 2026-10-01 16:20:51 +02:00
1 parent 249973dd69
commit 6dda186bdf
15 files changed
+416 -50

No files matched your search

+7 -3
View File
@@ -92,8 +92,12 @@ 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** (`/register`): no invite code; the form asks for email and display name only (no password); the page says that the user gets a mail with a link, then an admin
"registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while 20 wait. checks the request, and stays on "registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while
20 wait. The mail link opens `/confirm-email#<token>` (the token stays in the fragment and goes to Auth in a POST
body); the page asks for the password (and its repeat) and confirms on a button click, not on load, so mail scanners do not burn the link. An expired or used
link and the page after the send offer "send the link
again" (same answer for any address).
- **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`.
@@ -102,7 +106,7 @@ To Be Played is split into three repositories:
typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge
shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept
and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change
and menu opening. The login tells a waiting user that the admin has not decided yet. and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet.
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes - **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). "Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
+18 -2
View File
@@ -25,16 +25,27 @@
"PASSWORD": "Passwort", "PASSWORD": "Passwort",
"PASSWORD_CONFIRMATION": "Passwort bestätigen", "PASSWORD_CONFIRMATION": "Passwort bestätigen",
"SUBMIT": "Konto erstellen", "SUBMIT": "Konto erstellen",
"SUCCESS": "Registrierung gesendet. Du bekommst eine Mail, sobald der Admin entschieden hat.", "SUCCESS": "Wir haben dir eine Mail geschickt. Klick auf den Link darin, um deine Adresse zu bestätigen und dein Passwort zu wählen; erst dann sieht der Admin deine Anfrage.",
"RESEND_EMAIL": "E-Mail",
"RESEND": "Link noch einmal senden",
"RESEND_SENT": "Wenn für diese Adresse eine unbestätigte Registrierung besteht, ist ein neuer Link unterwegs.",
"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.",
"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.", "INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.",
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal." "ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal."
}, },
"CONFIRM_EMAIL": {
"TITLE": "E-Mail-Adresse bestätigen",
"INTRO": "Wähle ein Passwort, um die Adresse deiner Registrierung zu bestätigen.",
"SUBMIT": "Bestätigen",
"DONE": "Deine Adresse ist bestätigt. Ein Admin prüft deine Anfrage; du bekommst eine Mail, sobald entschieden ist.",
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 24 Stunden. Fordere einen neuen an:",
"TO_LOGIN": "Anmelden"
},
"SETTINGS": { "SETTINGS": {
"PROFILE_STYLE": "Aussehen des Profils", "PROFILE_STYLE": "Aussehen des Profils",
"PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.", "PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.",
@@ -561,6 +572,11 @@
"DENY_TITLE": "{{email}} ablehnen?", "DENY_TITLE": "{{email}} ablehnen?",
"DENY_TEXT": "Der Nutzer bekommt eine Mail und kann sich nicht anmelden. Die Anfrage wird nach 30 Tagen endgültig gelöscht.", "DENY_TEXT": "Der Nutzer bekommt eine Mail und kann sich nicht anmelden. Die Anfrage wird nach 30 Tagen endgültig gelöscht.",
"DONE_ACCEPT": "Registrierung angenommen", "DONE_ACCEPT": "Registrierung angenommen",
"UNCONFIRMED": "Nicht bestätigt",
"DELETE_UNCONFIRMED_TITLE": "{{email}} löschen?",
"DELETE_UNCONFIRMED_TEXT": "Die Adresse ist nicht bestätigt. Die Anfrage wird sofort und endgültig entfernt; die Adresse ist wieder frei.",
"ERROR_TARGET_IS_UNCONFIRMED": "Die Adresse ist nicht bestätigt: lösche die Anfrage.",
"ERROR_NOT_UNCONFIRMED": "Die Anfrage ist schon bestätigt.",
"DONE_DENY": "Registrierung abgelehnt", "DONE_DENY": "Registrierung abgelehnt",
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.", "ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab." "ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab."
+18 -2
View File
@@ -25,16 +25,27 @@
"PASSWORD": "Password", "PASSWORD": "Password",
"PASSWORD_CONFIRMATION": "Confirm password", "PASSWORD_CONFIRMATION": "Confirm password",
"SUBMIT": "Create account", "SUBMIT": "Create account",
"SUCCESS": "Registration sent. You get a mail as soon as the admin has decided.", "SUCCESS": "We sent you a mail. Click the link in it to confirm your address and choose your password; only then does the admin see your request.",
"RESEND_EMAIL": "Email",
"RESEND": "Send the link again",
"RESEND_SENT": "If this address has an unconfirmed registration, a new link is on its way.",
"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.",
"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.", "INTRO": "You get a mail with a link: there you confirm your address and choose your password. Then an admin checks your account and you get a mail with the decision.",
"ERROR_PENDING_LIMIT": "Too many registrations wait for the admin right now. Please try again later." "ERROR_PENDING_LIMIT": "Too many registrations wait for the admin right now. Please try again later."
}, },
"CONFIRM_EMAIL": {
"TITLE": "Confirm your email address",
"INTRO": "Choose a password to confirm the address of your registration.",
"SUBMIT": "Confirm",
"DONE": "Your address is confirmed. An admin checks your request; you get a mail as soon as it is decided.",
"INVALID": "This link is unknown, already used or older than 24 hours. Request a new one:",
"TO_LOGIN": "Log in"
},
"SETTINGS": { "SETTINGS": {
"PROFILE_STYLE": "Profile look", "PROFILE_STYLE": "Profile look",
"PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.", "PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.",
@@ -561,6 +572,11 @@
"DENY_TITLE": "Deny {{email}}?", "DENY_TITLE": "Deny {{email}}?",
"DENY_TEXT": "The user gets a mail and cannot log in. The request is removed for good after 30 days.", "DENY_TEXT": "The user gets a mail and cannot log in. The request is removed for good after 30 days.",
"DONE_ACCEPT": "Registration accepted", "DONE_ACCEPT": "Registration accepted",
"UNCONFIRMED": "Not confirmed",
"DELETE_UNCONFIRMED_TITLE": "Delete {{email}}?",
"DELETE_UNCONFIRMED_TEXT": "The address is not confirmed. The request is removed at once for good; the address is free again.",
"ERROR_TARGET_IS_UNCONFIRMED": "The address is not confirmed: delete the request.",
"ERROR_NOT_UNCONFIRMED": "The request is confirmed already.",
"DONE_DENY": "Registration denied", "DONE_DENY": "Registration denied",
"ERROR_NOT_PENDING": "This registration is decided already.", "ERROR_NOT_PENDING": "This registration is decided already.",
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first." "ERROR_TARGET_IS_PENDING": "Accept or deny the registration first."
+2
View File
@@ -7,6 +7,7 @@ import LoginPage from './pages/LoginPage';
import ImprintPage from './pages/ImprintPage'; import ImprintPage from './pages/ImprintPage';
import Dashboard from "./pages/Dashboard.jsx"; import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx'; import RegistrationPage from './pages/RegistrationPage.jsx';
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx'; import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx'; import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
@@ -71,6 +72,7 @@ function App() {
<Route path="/" element={<LandingPage/>}/> <Route path="/" element={<LandingPage/>}/>
<Route path="/login" element={<LoginPage/>}/> <Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/> <Route path="/register" element={<RegistrationPage/>}/>
<Route path="/confirm-email" element={<ConfirmEmailPage/>}/>
<Route element={<RequireSession/>}> <Route element={<RequireSession/>}>
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/> <Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
<Route path="/library" element={<GameLibrary/>}/> <Route path="/library" element={<GameLibrary/>}/>
+58
View File
@@ -0,0 +1,58 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import FeedbackAlert from './FeedbackAlert.jsx';
import FormField from './FormField.jsx';
import {clientFetch} from '../utils/api.js';
/**
* "Send the confirmation link again". Auth answers the same for any address, so does this form.
*/
export default function ResendConfirmation({defaultEmail = ''}) {
const {t} = useTranslation();
const [email, setEmail] = useState(defaultEmail);
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState({message: '', isError: false});
const resend = async () => {
setIsLoading(true);
setFeedback({message: '', isError: false});
try {
const response = await clientFetch(`${import.meta.env.VITE_API_URL}/api/registration/resend`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email}),
});
if (response.ok) {
setFeedback({message: t('REGISTRATION.RESEND_SENT'), isError: false});
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
setFeedback({message: t('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', {minutes}), isError: true});
} else {
setFeedback({message: t('REGISTRATION.ERROR_GENERIC'), isError: true});
}
} catch (error) {
console.error('Resend API error:', error);
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-3">
<FormField id="resend-email" label={t('REGISTRATION.RESEND_EMAIL')} t={t}>
<Input id="resend-email" type="email" autoComplete="email" value={email}
onChange={(e) => setEmail(e.target.value)}/>
</FormField>
<Button type="button" variant="outline" disabled={isLoading} onClick={resend} className="w-full">
{isLoading && <Loader2 className="animate-spin"/>}
{t('REGISTRATION.RESEND')}
</Button>
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
</div>
);
}
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import ResendConfirmation from './ResendConfirmation.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options?.minutes === undefined ? key : `${key}:${options.minutes}`),
i18n: {language: 'de'},
}),
}));
describe('ResendConfirmation', () => {
afterEach(cleanup);
it('sends the prefilled address and shows the same answer for any address', async () => {
const mock = fetchRoutes({'POST /api/registration/resend': json(204, null)});
render(<ResendConfirmation defaultEmail="a@b.de"/>);
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.RESEND'}));
expect(await screen.findByText('REGISTRATION.RESEND_SENT')).toBeTruthy();
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({email: 'a@b.de'});
});
it('shows the wait time when the IP is blocked', async () => {
fetchRoutes({
'POST /api/registration/resend': {...json(429, {}), headers: new Headers({'Retry-After': '900'})},
});
render(<ResendConfirmation/>);
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.RESEND'}));
expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy();
});
});
+18 -4
View File
@@ -18,7 +18,7 @@ import {
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {decideUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js'; import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js';
@@ -29,12 +29,14 @@ const REFUSAL_KEY = {
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED', ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING', NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING', TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
}; };
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null); const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
/** /**
* PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait. Auth checks the role; a * PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait, delete the ones whose address is not confirmed. Auth checks the role; a
* 403 shows "no access". * 403 shows "no access".
*/ */
export default function AdminUsersPage() { export default function AdminUsersPage() {
@@ -45,7 +47,7 @@ export default function AdminUsersPage() {
const [pendingOnly, setPendingOnly] = useState(false); const [pendingOnly, setPendingOnly] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [status, setStatus] = useState('loading'); const [status, setStatus] = useState('loading');
// {kind: 'block' | 'delete' | 'accept' | 'deny', user} // {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny', user}
const [pending, setPending] = useState(null); const [pending, setPending] = useState(null);
const [typedEmail, setTypedEmail] = useState(''); const [typedEmail, setTypedEmail] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -92,6 +94,11 @@ export default function AdminUsersPage() {
title: t('ADMIN.DELETE_TITLE', {email}), text: t('ADMIN.DELETE_TEXT'), action: 'ADMIN.DELETE', title: t('ADMIN.DELETE_TITLE', {email}), text: t('ADMIN.DELETE_TEXT'), action: 'ADMIN.DELETE',
done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUser(pending.user.id), done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUser(pending.user.id),
}, },
deleteUnconfirmed: {
title: t('ADMIN.DELETE_UNCONFIRMED_TITLE', {email}), text: t('ADMIN.DELETE_UNCONFIRMED_TEXT'),
action: 'ADMIN.DELETE', done: 'ADMIN.DONE_DELETE', destructive: true,
run: () => deleteUnconfirmedUser(pending.user.id),
},
block: { block: {
...(pending.user.blocked ...(pending.user.blocked
? {title: t('ADMIN.UNBLOCK'), text: email, action: 'ADMIN.UNBLOCK', done: 'ADMIN.DONE_UNBLOCK'} ? {title: t('ADMIN.UNBLOCK'), text: email, action: 'ADMIN.UNBLOCK', done: 'ADMIN.DONE_UNBLOCK'}
@@ -181,6 +188,7 @@ export default function AdminUsersPage() {
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>} {user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>}
{user.steamLinked && <Badge variant="secondary">{t('ADMIN.STEAM')}</Badge>} {user.steamLinked && <Badge variant="secondary">{t('ADMIN.STEAM')}</Badge>}
{user.status === 'UNCONFIRMED' && <Badge variant="outline">{t('ADMIN.UNCONFIRMED')}</Badge>}
{user.status === 'PENDING' && <Badge>{t('ADMIN.PENDING')}</Badge>} {user.status === 'PENDING' && <Badge>{t('ADMIN.PENDING')}</Badge>}
{user.blocked && <Badge variant="destructive">{t('ADMIN.BLOCKED')}</Badge>} {user.blocked && <Badge variant="destructive">{t('ADMIN.BLOCKED')}</Badge>}
{user.deletedAt && ( {user.deletedAt && (
@@ -189,6 +197,12 @@ export default function AdminUsersPage() {
</Badge> </Badge>
)} )}
</div> </div>
{user.status === 'UNCONFIRMED' && (
<Button type="button" variant="outline" size="sm" className="border-destructive text-destructive"
onClick={() => setPending({kind: 'deleteUnconfirmed', user})}>
<Trash2/>{t('ADMIN.DELETE')}
</Button>
)}
{user.status === 'PENDING' && ( {user.status === 'PENDING' && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" variant="outline" size="sm" <Button type="button" variant="outline" size="sm"
@@ -201,7 +215,7 @@ export default function AdminUsersPage() {
</Button> </Button>
</div> </div>
)} )}
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && ( {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" variant="outline" size="sm" <Button type="button" variant="outline" size="sm"
onClick={() => setPending({kind: 'block', user})}> onClick={() => setPending({kind: 'block', user})}>
+19
View File
@@ -15,6 +15,7 @@ const PLAYER = {
lastLoginDate: null, steamLinked: true, blocked: false, deletedAt: null, status: 'ACTIVE', lastLoginDate: null, steamLinked: true, blocked: false, deletedAt: null, status: 'ACTIVE',
}; };
const NEWCOMER = {...PLAYER, id: 8, email: 'new@example.com', steamLinked: false, status: 'PENDING'}; const NEWCOMER = {...PLAYER, id: 8, email: 'new@example.com', steamLinked: false, status: 'PENDING'};
const UNCONFIRMED = {...PLAYER, id: 10, email: 'unconfirmed@example.com', steamLinked: false, status: 'UNCONFIRMED'};
const DENIED = {...PLAYER, id: 9, email: 'denied@example.com', steamLinked: false, status: 'DENIED', const DENIED = {...PLAYER, id: 9, email: 'denied@example.com', steamLinked: false, status: 'DENIED',
deletedAt: '2026-10-01T10:00:00Z'}; deletedAt: '2026-10-01T10:00:00Z'};
const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false}; const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false};
@@ -139,6 +140,24 @@ describe('AdminUsersPage', () => {
await waitFor(() => expect(calls('GET').at(-1)[0]).toContain('status=PENDING&page=0')); await waitFor(() => expect(calls('GET').at(-1)[0]).toContain('status=PENDING&page=0'));
}); });
it('marks an unconfirmed request, offers no accept and deletes it hard after the confirm', async () => {
withRoutes({
'GET /api/admin/users': json(200, page([UNCONFIRMED])),
'DELETE /api/admin/users/10/unconfirmed': json(204),
});
renderPage();
expect(await screen.findByText('ADMIN.UNCONFIRMED')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'ADMIN.ACCEPT'})).toBeNull();
expect(screen.queryByRole('button', {name: 'ADMIN.DENY'})).toBeNull();
expect(screen.queryByRole('button', {name: 'ADMIN.BLOCK'})).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.DELETE'}));
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.DELETE'}).at(-1));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_DELETE'));
expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/10\/unconfirmed$/);
});
it('says no access to a user without the admin role', async () => { it('says no access to a user without the admin role', async () => {
withRoutes({'GET /api/admin/users': json(403, {})}); withRoutes({'GET /api/admin/users': json(403, {})});
renderPage(); renderPage();
+100
View File
@@ -0,0 +1,100 @@
import {useState} from 'react';
import {Link, useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx';
import ResendConfirmation from '../components/ResendConfirmation.jsx';
import {confirmEmailSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
/**
* The link of the confirmation mail. The token is in the fragment, so no server log sees it. It is sent only on the
* click: a mail scanner that opens the link must not use it up. The owner of the address chooses the password here.
*/
export default function ConfirmEmailPage() {
const token = useLocation().hash.slice(1);
// A new link in the same tab only changes the fragment: key restarts the state.
return <ConfirmEmail key={token} token={token}/>;
}
function ConfirmEmail({token}) {
const {t} = useTranslation();
const [state, setState] = useState(token ? 'idle' : 'invalid');
const {register, handleSubmit, formState: {errors, isSubmitting}} = useForm({
resolver: zodResolver(confirmEmailSchema),
defaultValues: {password: '', passwordConfirmation: ''},
});
const [error, setError] = useState('');
const confirm = async ({password}) => {
setError('');
try {
const response = await clientFetch(`${import.meta.env.VITE_API_URL}/api/registration/confirm`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token, password}),
});
if (response.ok) {
setState('done');
} else if (response.status === 410 || response.status === 400) {
setState('invalid');
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
setError(t('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', {minutes}));
} else {
setError(t('REGISTRATION.ERROR_GENERIC'));
}
} catch (err) {
console.error('Confirm API error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
return (
<AuthLayout title={t('CONFIRM_EMAIL.TITLE')}>
{state === 'idle' && <form onSubmit={handleSubmit(confirm)} className="space-y-4" noValidate>
<p className="text-sm text-muted-foreground">{t('CONFIRM_EMAIL.INTRO')}</p>
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password} t={t}>
<Input
id="password"
type="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
{...register('password')}
/>
</FormField>
<FormField id="passwordConfirmation" label={t('REGISTRATION.PASSWORD_CONFIRMATION')} error={errors.passwordConfirmation} t={t}>
<Input
id="passwordConfirmation"
type="password"
autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation}
{...register('passwordConfirmation')}
/>
</FormField>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="animate-spin"/>}
{t('CONFIRM_EMAIL.SUBMIT')}
</Button>
<FeedbackAlert message={error} isError/>
</form>}
{state === 'done' && <>
<FeedbackAlert message={t('CONFIRM_EMAIL.DONE')} isError={false}/>
<Link to="/login" className="block text-center text-sm font-medium text-primary hover:underline">
{t('CONFIRM_EMAIL.TO_LOGIN')}
</Link>
</>}
{state === 'invalid' && <>
<FeedbackAlert message={t('CONFIRM_EMAIL.INVALID')} isError/>
<ResendConfirmation/>
</>}
</AuthLayout>
);
}
+80
View File
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {Link, MemoryRouter, Route, Routes} from 'react-router-dom';
import ConfirmEmailPage from './ConfirmEmailPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const renderPage = (hash) => render(
<MemoryRouter initialEntries={[`/confirm-email${hash}`]}><ConfirmEmailPage/></MemoryRouter>,
);
function submitPasswords(password, repeat = password) {
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: password}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: repeat}});
fireEvent.click(screen.getByRole('button', {name: 'CONFIRM_EMAIL.SUBMIT'}));
}
describe('ConfirmEmailPage', () => {
afterEach(cleanup);
it('sends the fragment token and the chosen password only on the click and then says it is confirmed', async () => {
const mock = fetchRoutes({'POST /api/registration/confirm': json(204, null)});
renderPage('#tok-123');
expect(mock).not.toHaveBeenCalled();
submitPasswords('secret123');
expect(await screen.findByText('CONFIRM_EMAIL.DONE')).toBeTruthy();
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({token: 'tok-123', password: 'secret123'});
});
it.each([
['a short password', 'short', 'short', 'VALIDATION.PASSWORD_TOO_SHORT'],
['a different repeat', 'secret123', 'secret124', 'VALIDATION.PASSWORD_MISMATCH'],
])('sends nothing for %s', async (_name, password, repeat, message) => {
const mock = fetchRoutes({});
renderPage('#tok-123');
submitPasswords(password, repeat);
expect(await screen.findByText(message)).toBeTruthy();
expect(mock).not.toHaveBeenCalled();
});
it('shows the invalid text and the resend form for a used or expired link', async () => {
fetchRoutes({'POST /api/registration/confirm': json(410, {code: 'CONFIRMATION_INVALID'})});
renderPage('#old');
submitPasswords('secret123');
expect(await screen.findByText('CONFIRM_EMAIL.INVALID')).toBeTruthy();
expect(screen.getByRole('button', {name: 'REGISTRATION.RESEND'})).toBeTruthy();
});
it('a new link in the same tab shows the password form again', async () => {
fetchRoutes({'POST /api/registration/confirm': json(410, {code: 'CONFIRMATION_INVALID'})});
render(
<MemoryRouter initialEntries={['/confirm-email#old']}>
<Routes><Route path="/confirm-email" element={<ConfirmEmailPage/>}/></Routes>
<Link to="/confirm-email#new">open the new link</Link>
</MemoryRouter>,
);
submitPasswords('secret123');
expect(await screen.findByText('CONFIRM_EMAIL.INVALID')).toBeTruthy();
fireEvent.click(screen.getByText('open the new link'));
expect(screen.getByLabelText('REGISTRATION.PASSWORD')).toBeTruthy();
});
it('is invalid at once without a token and sends no request', () => {
const mock = fetchRoutes({});
renderPage('');
expect(screen.getByText('CONFIRM_EMAIL.INVALID')).toBeTruthy();
expect(mock).not.toHaveBeenCalled();
});
});
+6 -27
View File
@@ -11,6 +11,7 @@ import {Input} from '@/components/ui/input';
import AuthLayout from '../components/AuthLayout.jsx'; import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx'; import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx'; import FormField from '../components/FormField.jsx';
import ResendConfirmation from '../components/ResendConfirmation.jsx';
import {registrationSchema} from '../utils/validation.js'; import {registrationSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js'; import {clientFetch} from '../utils/api.js';
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js'; import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
@@ -25,8 +26,8 @@ const BACKEND_ERROR_KEY = {
export default function RegistrationPage() { export default function RegistrationPage() {
const {t} = useTranslation(); const {t} = useTranslation();
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. // Set once sent: the request waits for the confirmation, a second one would only be refused.
const [requested, setRequested] = useState(false); const [requestedEmail, setRequestedEmail] = useState(null);
const { const {
register, register,
@@ -36,8 +37,6 @@ export default function RegistrationPage() {
resolver: zodResolver(registrationSchema), resolver: zodResolver(registrationSchema),
defaultValues: { defaultValues: {
email: '', email: '',
password: '',
passwordConfirmation: '',
displayName: '', displayName: '',
}, },
}); });
@@ -51,7 +50,6 @@ export default function RegistrationPage() {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
email: values.email, email: values.email,
password: values.password,
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',
@@ -60,7 +58,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});
setRequested(true); setRequestedEmail(values.email);
return; return;
} }
@@ -90,7 +88,7 @@ export default function RegistrationPage() {
</>} </>}
> >
<p className="mb-4 text-sm text-muted-foreground">{t('REGISTRATION.INTRO')}</p> <p className="mb-4 text-sm text-muted-foreground">{t('REGISTRATION.INTRO')}</p>
{!requested && <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate> {requestedEmail === null && <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"
@@ -101,26 +99,6 @@ export default function RegistrationPage() {
/> />
</FormField> </FormField>
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password} t={t}>
<Input
id="password"
type="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
{...register('password')}
/>
</FormField>
<FormField id="passwordConfirmation" label={t('REGISTRATION.PASSWORD_CONFIRMATION')} error={errors.passwordConfirmation} t={t}>
<Input
id="passwordConfirmation"
type="password"
autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation}
{...register('passwordConfirmation')}
/>
</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"
@@ -138,6 +116,7 @@ export default function RegistrationPage() {
</form>} </form>}
<FeedbackAlert message={feedback.message} isError={feedback.isError}/> <FeedbackAlert message={feedback.message} isError={feedback.isError}/>
{requestedEmail !== null && <ResendConfirmation defaultEmail={requestedEmail}/>}
</AuthLayout> </AuthLayout>
); );
} }
+20 -2
View File
@@ -21,8 +21,6 @@ const answerRegistration = (status, code, headers = {}, message = undefined) =>
function submitValidForm() { function submitValidForm() {
render(<MemoryRouter><RegistrationPage/></MemoryRouter>); render(<MemoryRouter><RegistrationPage/></MemoryRouter>);
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_CONFIRMATION'), {target: {value: 'secret123'}});
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'})); fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'}));
} }
@@ -40,6 +38,26 @@ describe('RegistrationPage', () => {
expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy(); expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy();
}); });
it('shows the resend form with the typed address after a successful send', async () => {
fetchRoutes({'POST /api/registration/new': json(200, {userId: 1})});
submitValidForm();
expect(await screen.findByText('REGISTRATION.SUCCESS')).toBeTruthy();
expect(screen.getByLabelText('REGISTRATION.RESEND_EMAIL').value).toBe('a@b.de');
});
it('asks for no password and sends none: the address owner chooses it when confirming', async () => {
answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
submitValidForm();
await screen.findByText('REGISTRATION.ERROR_EMAIL_IN_USE');
expect(screen.queryByLabelText('REGISTRATION.PASSWORD')).toBeNull();
expect(screen.queryByLabelText('REGISTRATION.PASSWORD_CONFIRMATION')).toBeNull();
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('password');
});
it('asks for no Steam ID and sends none', async () => { it('asks for no Steam ID and sends none', async () => {
answerRegistration(409, 'EMAIL_ALREADY_IN_USE'); answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
+7 -1
View File
@@ -7,7 +7,7 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
/** /**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise). * GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
* *
* @param status PENDING, ACTIVE or DENIED; empty for all * @param status UNCONFIRMED, PENDING, ACTIVE or DENIED; empty for all
*/ */
export const fetchUsers = (query, status, page, size, init) => export const fetchUsers = (query, status, page, size, init) =>
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, ...(status && {status}), page, size})}`, init); apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, ...(status && {status}), page, size})}`, init);
@@ -34,6 +34,12 @@ export const setUserBlocked = (userId, blocked) =>
body: JSON.stringify({blocked}), body: JSON.stringify({blocked}),
}); });
/**
* Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status.
*/
export const deleteUnconfirmedUser = (userId) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/unconfirmed`, {method: 'DELETE'});
export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'}); export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'});
/** /**
+7 -3
View File
@@ -24,12 +24,16 @@ const displayNameField = z
message: 'VALIDATION.DISPLAY_NAME_INVALID', message: 'VALIDATION.DISPLAY_NAME_INVALID',
}); });
export const registrationSchema = z // No password: the address owner chooses it on the confirm page.
export const registrationSchema = z.object({
email: emailField,
displayName: displayNameField,
});
export const confirmEmailSchema = z
.object({ .object({
email: emailField,
password: passwordField, password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'), passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
displayName: displayNameField,
}) })
.refine((d) => d.password === d.passwordConfirmation, { .refine((d) => d.password === d.passwordConfirmation, {
path: ['passwordConfirmation'], path: ['passwordConfirmation'],
+19 -6
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {registrationSchema, settingsSchema} from './validation.js'; import {confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
const settings = () => ({useDarkTheme: true, language: 'de'}); const settings = () => ({useDarkTheme: true, language: 'de'});
@@ -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', email: 'a@b.de',
steamId: '76561198005679788', steamId: '76561198005679788',
})).not.toHaveProperty('steamId'); })).not.toHaveProperty('steamId');
}); });
@@ -30,11 +30,24 @@ describe('displayName validation', () => {
}); });
describe('registration password', () => { describe('confirm page 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(confirmEmailSchema.safeParse({password, passwordConfirmation: password}).success).toBe(false);
email: 'a@b.de', password, passwordConfirmation: password, });
}).success).toBe(false);
it('rejects a short password and one that differs from its repeat', () => {
expect(confirmEmailSchema.safeParse({password: 'short', passwordConfirmation: 'short'}).success).toBe(false);
expect(confirmEmailSchema.safeParse({password: 'password1', passwordConfirmation: 'password2'}).success).toBe(false);
});
it('takes a good password', () => {
expect(confirmEmailSchema.safeParse({password: 'password1', passwordConfirmation: 'password1'}).success).toBe(true);
});
});
describe('registration', () => {
it('has no password field', () => {
expect(registrationSchema.parse({email: 'a@b.de', password: 'password1'})).not.toHaveProperty('password');
}); });
}); });