feat(password): add reset and change password pages

/reset-password page for the email request and the new password,
ChangePasswordCard in settings, password reset button on the admin
users page, 'Forgot password?' link on the login page, de/en texts.
This commit is contained in:
lobo committed 2026-10-02 10:35:08 +02:00
1 parent f71e8f7aff
commit 6627a8db66
19 files changed
+565 -25

No files matched your search

+5
View File
@@ -102,6 +102,11 @@ To Be Played is split into three repositories:
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).
- **Forgot password** (`/reset-password`): the login page links to it. Without a fragment it asks for the email and
shows the same text for any address. The mail link opens `/reset-password#<token>` (fragment, sent in a POST body on
the button click only): the page asks for the new password twice. A used or expired link shows the email form again.
**Change password** is a card in the settings (current password and the new one twice); the other logins end, this
one stays. The admin users page has "Send password reset" for an active, unblocked user.
- **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`.
+31
View File
@@ -10,6 +10,7 @@
"PASSWORD": "Passwort",
"SUBMIT": "Spiel betreten",
"NO_ACCOUNT": "Noch kein Konto?",
"FORGOT_PASSWORD": "Passwort vergessen?",
"ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.",
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein.",
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
@@ -38,6 +39,19 @@
"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."
},
"RESET_PASSWORD": {
"TITLE": "Passwort zurücksetzen",
"EMAIL": "E-Mail",
"SEND": "Link zum Zurücksetzen senden",
"SENT": "Wenn ein aktives Konto diese Adresse hat, ist ein Link unterwegs. Er ist 1 Stunde gültig.",
"INTRO": "Wähle dein neues Passwort. Danach enden alle deine Anmeldungen.",
"SUBMIT": "Passwort speichern",
"DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.",
"TO_LOGIN": "Anmelden",
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 1 Stunde. Fordere einen neuen an:",
"ERROR_TOO_MANY": "Zu viele Versuche. Bitte versuche es in {{minutes}} Minuten noch einmal.",
"ERROR_GENERIC": "Das hat nicht geklappt. Bitte versuche es noch einmal."
},
"CONFIRM_EMAIL": {
"TITLE": "E-Mail-Adresse bestätigen",
"INTRO": "Wähle ein Passwort, um die Adresse deiner Registrierung zu bestätigen.",
@@ -126,6 +140,17 @@
},
"VERSION": "Version",
"VERSION_UNKNOWN": "unbekannt",
"CHANGE_PASSWORD": {
"TITLE": "Passwort ändern",
"CURRENT": "Aktuelles Passwort",
"NEW": "Neues Passwort",
"REPEAT": "Neues Passwort wiederholen",
"SUBMIT": "Passwort ändern",
"DONE": "Passwort geändert. Deine anderen Anmeldungen sind beendet.",
"WRONG_PASSWORD": "Das aktuelle Passwort ist falsch.",
"TOO_MANY": "Zu viele falsche Passwörter. Versuche es in {{minutes}} Minuten noch einmal.",
"ERROR": "Das Passwort konnte nicht geändert werden."
},
"DELETE_ACCOUNT": {
"TITLE": "Konto löschen",
"EXPLAIN": "Dein Konto wird sofort gesperrt: kein Login, kein öffentliches Profil. Nach 30 Tagen wird es endgültig entfernt, mit Bibliothek, Backlog und Statistiken.",
@@ -151,6 +176,7 @@
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
"CURRENT_PASSWORD_REQUIRED": "Gib dein aktuelles Passwort ein.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
},
@@ -559,6 +585,7 @@
"DONE_DELETE": "Nutzer gelöscht.",
"ERROR_SELF_ACTION": "Das geht nicht mit dem eigenen Konto.",
"ERROR_TARGET_IS_ADMIN": "Das geht nicht mit einem anderen Admin.",
"ERROR_NOT_ACTIVE": "Nur ein aktives Konto kann eine Reset-Mail bekommen.",
"ERROR_ALREADY_DELETED": "Das Konto ist schon gelöscht.",
"ERROR": "Die Aktion ist fehlgeschlagen.",
"PAGE_OF": "Seite {{current}} von {{total}}",
@@ -580,6 +607,10 @@
"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",
"SEND_PASSWORD_RESET": "Passwort-Reset senden",
"PASSWORD_RESET_TITLE": "Passwort-Reset an {{email}} senden?",
"PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.",
"DONE_PASSWORD_RESET": "Reset-Mail gesendet.",
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab."
},
+31
View File
@@ -10,6 +10,7 @@
"PASSWORD": "Password",
"SUBMIT": "Enter the game",
"NO_ACCOUNT": "No account yet?",
"FORGOT_PASSWORD": "Forgot password?",
"ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.",
"ERROR_PASSWORD": "The password must have at least one character.",
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
@@ -38,6 +39,19 @@
"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."
},
"RESET_PASSWORD": {
"TITLE": "Reset password",
"EMAIL": "Email",
"SEND": "Send reset link",
"SENT": "If an active account has this address, a link is on its way. It is valid for 1 hour.",
"INTRO": "Choose your new password. All your logins end after that.",
"SUBMIT": "Save password",
"DONE": "Your password is changed. Log in with the new one.",
"TO_LOGIN": "Log in",
"INVALID": "This link is unknown, already used or older than 1 hour. Request a new one:",
"ERROR_TOO_MANY": "Too many attempts. Please try again in {{minutes}} minutes.",
"ERROR_GENERIC": "That did not work. Please try again."
},
"CONFIRM_EMAIL": {
"TITLE": "Confirm your email address",
"INTRO": "Choose a password to confirm the address of your registration.",
@@ -126,6 +140,17 @@
},
"VERSION": "Version",
"VERSION_UNKNOWN": "unknown",
"CHANGE_PASSWORD": {
"TITLE": "Change password",
"CURRENT": "Current password",
"NEW": "New password",
"REPEAT": "Repeat new password",
"SUBMIT": "Change password",
"DONE": "Password changed. Your other logins are ended.",
"WRONG_PASSWORD": "The current password is wrong.",
"TOO_MANY": "Too many wrong passwords. Try again in {{minutes}} minutes.",
"ERROR": "The password could not be changed."
},
"DELETE_ACCOUNT": {
"TITLE": "Delete account",
"EXPLAIN": "Your account is blocked at once: no login, no public profile. After 30 days it is removed for good, with your library, backlog and stats.",
@@ -151,6 +176,7 @@
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
"PASSWORD_MISMATCH": "Passwords do not match.",
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
},
@@ -559,6 +585,7 @@
"DONE_DELETE": "User deleted.",
"ERROR_SELF_ACTION": "You cannot do this to your own account.",
"ERROR_TARGET_IS_ADMIN": "You cannot do this to another admin.",
"ERROR_NOT_ACTIVE": "Only an active account can get a reset mail.",
"ERROR_ALREADY_DELETED": "The account is already deleted.",
"ERROR": "The action failed.",
"PAGE_OF": "Page {{current}} of {{total}}",
@@ -580,6 +607,10 @@
"ERROR_TARGET_IS_UNCONFIRMED": "The address is not confirmed: delete the request.",
"ERROR_NOT_UNCONFIRMED": "The request is confirmed already.",
"DONE_DENY": "Registration denied",
"SEND_PASSWORD_RESET": "Send password reset",
"PASSWORD_RESET_TITLE": "Send a password reset to {{email}}?",
"PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.",
"DONE_PASSWORD_RESET": "Reset mail sent.",
"ERROR_NOT_PENDING": "This registration is decided already.",
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first."
},
+2
View File
@@ -8,6 +8,7 @@ import ImprintPage from './pages/ImprintPage';
import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx';
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
import ResetPasswordPage from './pages/ResetPasswordPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
@@ -75,6 +76,7 @@ function App() {
<Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/>
<Route path="/confirm-email" element={<ConfirmEmailPage/>}/>
<Route path="/reset-password" element={<ResetPasswordPage/>}/>
<Route element={<RequireSession/>}>
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
<Route path="/library" element={<GameLibrary/>}/>
+73
View File
@@ -0,0 +1,73 @@
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {KeyRound, Loader2} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import FormField from './FormField.jsx';
import {changePassword} from '../utils/account.js';
import {changePasswordSchema} from '../utils/validation.js';
const EMPTY = {currentPassword: '', password: '', passwordConfirmation: ''};
/**
* "Change password": Auth ends every other login and keeps this one.
*/
export default function ChangePasswordCard() {
const {t} = useTranslation();
const {register, handleSubmit, reset, formState: {errors, isSubmitting}} = useForm({
resolver: zodResolver(changePasswordSchema),
defaultValues: EMPTY,
});
const submit = async ({currentPassword, password}) => {
try {
const response = await changePassword(currentPassword, password);
if (response.status === 204) {
toast.success(t('SETTINGS.CHANGE_PASSWORD.DONE'));
reset(EMPTY);
} else if (response.status === 403) {
toast.error(t('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
toast.error(t('SETTINGS.CHANGE_PASSWORD.TOO_MANY', {minutes}));
} else if (response.status !== 401) {
toast.error(t('SETTINGS.CHANGE_PASSWORD.ERROR'));
}
} catch (err) {
console.error('Change password error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><KeyRound className="size-4"/>{t('SETTINGS.CHANGE_PASSWORD.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
<FormField id="currentPassword" label={t('SETTINGS.CHANGE_PASSWORD.CURRENT')} error={errors.currentPassword} t={t}>
<Input id="currentPassword" type="password" autoComplete="current-password"
aria-invalid={!!errors.currentPassword} {...register('currentPassword')}/>
</FormField>
<FormField id="newPassword" label={t('SETTINGS.CHANGE_PASSWORD.NEW')} error={errors.password} t={t}>
<Input id="newPassword" type="password" autoComplete="new-password"
aria-invalid={!!errors.password} {...register('password')}/>
</FormField>
<FormField id="newPasswordRepeat" label={t('SETTINGS.CHANGE_PASSWORD.REPEAT')} error={errors.passwordConfirmation} t={t}>
<Input id="newPasswordRepeat" type="password" autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
</FormField>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin"/>}
{t('SETTINGS.CHANGE_PASSWORD.SUBMIT')}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import ChangePasswordCard from './ChangePasswordCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const answers = (answer) => fetchRoutes({'POST /api/auth/password': answer});
function fillAndSubmit(current = 'old password', next = 'new password 1', repeat = next) {
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT'), {target: {value: current}});
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.NEW'), {target: {value: next}});
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.REPEAT'), {target: {value: repeat}});
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CHANGE_PASSWORD.SUBMIT'}));
}
describe('ChangePasswordCard', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('sends the current and the new password and clears the form on success', async () => {
const mock = answers(json(204));
render(<ChangePasswordCard/>);
fillAndSubmit();
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.DONE'));
expect(mock.mock.calls[0][0]).toMatch(/\/api\/auth\/password$/);
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({currentPassword: 'old password', newPassword: 'new password 1'});
expect(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT').value).toBe('');
});
it('tells a wrong current password', async () => {
answers(json(403, {code: 'INVALID_PASSWORD'}));
render(<ChangePasswordCard/>);
fillAndSubmit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
});
it('tells the lock with the minutes', async () => {
answers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
render(<ChangePasswordCard/>);
fillAndSubmit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.TOO_MANY'));
});
it('sends nothing when the repeat differs or the current password is empty', async () => {
const mock = fetchRoutes({});
render(<ChangePasswordCard/>);
fillAndSubmit('old password', 'new password 1', 'new password 2');
expect(await screen.findByText('VALIDATION.PASSWORD_MISMATCH')).toBeTruthy();
fillAndSubmit('', 'new password 1');
expect(await screen.findByText('VALIDATION.CURRENT_PASSWORD_REQUIRED')).toBeTruthy();
expect(mock).not.toHaveBeenCalled();
});
});
+19
View File
@@ -0,0 +1,19 @@
import {Input} from '@/components/ui/input';
import FormField from './FormField.jsx';
/**
* The new password and its repeat, for a form on confirmEmailSchema.
*/
export default function NewPasswordFields({register, errors, t}) {
return <>
<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>
</>;
}
+15 -3
View File
@@ -1,6 +1,6 @@
import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Ban, Check, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -18,7 +18,7 @@ import {
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx';
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, sendPasswordReset, setUserBlocked} from '../utils/admin.js';
import {formatDate} from '../utils/format.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
@@ -31,6 +31,7 @@ const REFUSAL_KEY = {
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
};
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
@@ -47,7 +48,7 @@ export default function AdminUsersPage() {
const [pendingOnly, setPendingOnly] = useState(false);
const [result, setResult] = useState(null);
const [status, setStatus] = useState('loading');
// {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny', user}
// {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny' | 'passwordReset', user}
const [pending, setPending] = useState(null);
const [typedEmail, setTypedEmail] = useState('');
const [busy, setBusy] = useState(false);
@@ -105,6 +106,11 @@ export default function AdminUsersPage() {
: {title: t('ADMIN.BLOCK_TITLE', {email}), text: t('ADMIN.BLOCK_TEXT'), action: 'ADMIN.BLOCK', done: 'ADMIN.DONE_BLOCK'}),
run: () => setUserBlocked(pending.user.id, !pending.user.blocked),
},
passwordReset: {
title: t('ADMIN.PASSWORD_RESET_TITLE', {email}), text: t('ADMIN.PASSWORD_RESET_TEXT'),
action: 'ADMIN.SEND_PASSWORD_RESET', done: 'ADMIN.DONE_PASSWORD_RESET',
run: () => sendPasswordReset(pending.user.id),
},
accept: {
title: t('ADMIN.ACCEPT_TITLE', {email}), text: t('ADMIN.ACCEPT_TEXT'), action: 'ADMIN.ACCEPT',
done: 'ADMIN.DONE_ACCEPT', run: () => decideUser(pending.user.id, true),
@@ -217,6 +223,12 @@ export default function AdminUsersPage() {
)}
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
<div className="flex gap-2">
{user.status === 'ACTIVE' && !user.blocked && (
<Button type="button" variant="outline" size="sm"
onClick={() => setPending({kind: 'passwordReset', user})}>
<KeyRound/>{t('ADMIN.SEND_PASSWORD_RESET')}
</Button>
)}
<Button type="button" variant="outline" size="sm"
onClick={() => setPending({kind: 'block', user})}>
{user.blocked ? <ShieldCheck/> : <Ban/>}
+29
View File
@@ -87,6 +87,35 @@ describe('AdminUsersPage', () => {
await waitFor(() => expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/7$/));
});
it('offers the password reset only for an active, unblocked non-admin and posts it after the confirm', async () => {
withRoutes({
'GET /api/admin/users': json(200, page([ADMIN, PLAYER, NEWCOMER, {...PLAYER, id: 11, email: 'b@example.com', blocked: true}])),
'POST /api/admin/users/7/password-reset': json(204),
});
renderPage();
await screen.findByText('player@example.com');
expect(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'})).toHaveLength(1);
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}));
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}).at(-1));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_PASSWORD_RESET'));
expect(calls('POST')[0][0]).toMatch(/\/api\/admin\/users\/7\/password-reset$/);
});
it('shows the reason when the user is not active any more', async () => {
withRoutes({
'GET /api/admin/users': json(200, page([PLAYER])),
'POST /api/admin/users/7/password-reset': json(409, {code: 'NOT_ACTIVE'}),
});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}));
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}).at(-1));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_NOT_ACTIVE'));
});
it('shows the reason when Auth refuses', async () => {
withRoutes({
'GET /api/admin/users': json(200, page([PLAYER])),
+2 -20
View File
@@ -6,10 +6,9 @@ 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 NewPasswordFields from '../components/NewPasswordFields.jsx';
import ResendConfirmation from '../components/ResendConfirmation.jsx';
import {confirmEmailSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
@@ -61,24 +60,7 @@ function ConfirmEmail({token}) {
<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>
<NewPasswordFields register={register} errors={errors} t={t}/>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="animate-spin"/>}
{t('CONFIRM_EMAIL.SUBMIT')}
+3
View File
@@ -116,6 +116,9 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
required
/>
<Link to="/reset-password" className="block text-sm text-muted-foreground hover:underline">
{t('LOGIN.FORGOT_PASSWORD')}
</Link>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
+6
View File
@@ -48,6 +48,12 @@ describe('LoginPage', () => {
expect(screen.getByText('LOGIN.SESSION_EXPIRED')).toBeTruthy();
});
it('links to the forgot password page', () => {
renderLogin(undefined);
expect(screen.getByRole('link', {name: 'LOGIN.FORGOT_PASSWORD'}).getAttribute('href')).toBe('/reset-password');
});
it('shows no message on a normal visit', () => {
renderLogin(undefined);
+137
View File
@@ -0,0 +1,137 @@
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 NewPasswordFields from '../components/NewPasswordFields.jsx';
import {confirmEmailSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
const post = (path, body) => clientFetch(`${import.meta.env.VITE_API_URL}${path}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const failureMessage = (t, response) => {
if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
return t('RESET_PASSWORD.ERROR_TOO_MANY', {minutes});
}
return t('RESET_PASSWORD.ERROR_GENERIC');
};
/**
* Both ends of "forgot password". Without a fragment: ask for the mail. With the fragment of the mail link: choose
* the new password. The token is in the fragment, so no server log sees it, and it is sent only on the click: a mail
* scanner that opens the link must not use it up.
*/
export default function ResetPasswordPage() {
const token = useLocation().hash.slice(1);
// A new link in the same tab only changes the fragment: key restarts the state.
return <ResetPassword key={token} token={token}/>;
}
function ResetPassword({token}) {
const {t} = useTranslation();
const [state, setState] = useState(token ? 'idle' : 'request');
return (
<AuthLayout title={t('RESET_PASSWORD.TITLE')}>
{state === 'idle' && (
<NewPasswordForm token={token} onDone={() => setState('done')} onInvalid={() => setState('invalid')}/>
)}
{state === 'done' && <>
<FeedbackAlert message={t('RESET_PASSWORD.DONE')} isError={false}/>
<Link to="/login" className="block text-center text-sm font-medium text-primary hover:underline">
{t('RESET_PASSWORD.TO_LOGIN')}
</Link>
</>}
{state === 'invalid' && <FeedbackAlert message={t('RESET_PASSWORD.INVALID')} isError/>}
{(state === 'request' || state === 'invalid') && <RequestForm/>}
</AuthLayout>
);
}
function RequestForm() {
const {t} = useTranslation();
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState({message: '', isError: false});
const send = async (event) => {
event.preventDefault();
setIsLoading(true);
setFeedback({message: '', isError: false});
try {
const response = await post('/api/auth/password-reset', {email});
setFeedback(response.ok
? {message: t('RESET_PASSWORD.SENT'), isError: false}
: {message: failureMessage(t, response), isError: true});
} catch (error) {
console.error('Password reset API error:', error);
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={send} className="space-y-3" noValidate>
<FormField id="reset-email" label={t('RESET_PASSWORD.EMAIL')} t={t}>
<Input id="reset-email" type="email" autoComplete="email" value={email}
onChange={(e) => setEmail(e.target.value)}/>
</FormField>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading && <Loader2 className="animate-spin"/>}
{t('RESET_PASSWORD.SEND')}
</Button>
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
</form>
);
}
function NewPasswordForm({token, onDone, onInvalid}) {
const {t} = useTranslation();
const [error, setError] = useState('');
const {register, handleSubmit, formState: {errors, isSubmitting}} = useForm({
resolver: zodResolver(confirmEmailSchema),
defaultValues: {password: '', passwordConfirmation: ''},
});
const submit = async ({password}) => {
setError('');
try {
const response = await post('/api/auth/password-reset/confirm', {token, password});
if (response.ok) {
onDone();
} else if (response.status === 410 || response.status === 400) {
onInvalid();
} else {
setError(failureMessage(t, response));
}
} catch (err) {
console.error('Password reset confirm error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
return (
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
<p className="text-sm text-muted-foreground">{t('RESET_PASSWORD.INTRO')}</p>
<NewPasswordFields register={register} errors={errors} t={t}/>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="animate-spin"/>}
{t('RESET_PASSWORD.SUBMIT')}
</Button>
<FeedbackAlert message={error} isError/>
</form>
);
}
+92
View File
@@ -0,0 +1,92 @@
// @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 ResetPasswordPage from './ResetPasswordPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const renderPage = (hash = '') => render(
<MemoryRouter initialEntries={[`/reset-password${hash}`]}><ResetPasswordPage/></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: 'RESET_PASSWORD.SUBMIT'}));
}
function submitEmail(email = 'a@b.de') {
fireEvent.change(screen.getByLabelText('RESET_PASSWORD.EMAIL'), {target: {value: email}});
fireEvent.click(screen.getByRole('button', {name: 'RESET_PASSWORD.SEND'}));
}
describe('ResetPasswordPage', () => {
afterEach(cleanup);
it('without a fragment asks for the email and shows the same text for any address', async () => {
const mock = fetchRoutes({'POST /api/auth/password-reset': json(204, null)});
renderPage();
submitEmail();
expect(await screen.findByText('RESET_PASSWORD.SENT')).toBeTruthy();
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({email: 'a@b.de'});
});
it('shows the lock text with the minutes on a 429', async () => {
fetchRoutes({'POST /api/auth/password-reset': {...json(429, {}), headers: new Headers({'Retry-After': '900'})}});
renderPage();
submitEmail();
expect(await screen.findByText('RESET_PASSWORD.ERROR_TOO_MANY')).toBeTruthy();
});
it('with a fragment sends the token and the new password only on the click and then offers the login', async () => {
const mock = fetchRoutes({'POST /api/auth/password-reset/confirm': json(204, null)});
renderPage('#tok-123');
expect(mock).not.toHaveBeenCalled();
submitPasswords('secret123');
expect(await screen.findByText('RESET_PASSWORD.DONE')).toBeTruthy();
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({token: 'tok-123', password: 'secret123'});
expect(screen.getByRole('link', {name: 'RESET_PASSWORD.TO_LOGIN'}).getAttribute('href')).toBe('/login');
});
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 email form again for a used or expired link', async () => {
fetchRoutes({'POST /api/auth/password-reset/confirm': json(410, {code: 'RESET_INVALID'})});
renderPage('#old');
submitPasswords('secret123');
expect(await screen.findByText('RESET_PASSWORD.INVALID')).toBeTruthy();
expect(screen.getByRole('button', {name: 'RESET_PASSWORD.SEND'})).toBeTruthy();
});
it('shows the lock text with the minutes when the confirm is limited', async () => {
fetchRoutes({
'POST /api/auth/password-reset/confirm': {...json(429, {}), headers: new Headers({'Retry-After': '900'})},
});
renderPage('#tok');
submitPasswords('secret123');
expect(await screen.findByText('RESET_PASSWORD.ERROR_TOO_MANY')).toBeTruthy();
});
});
+3
View File
@@ -7,6 +7,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import AccountInfoCard from '../components/AccountInfoCard.jsx';
import AppearanceCard from '../components/AppearanceCard.jsx';
import BuildVersionCard from '../components/BuildVersionCard.jsx';
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
import PreferencesCard from '../components/PreferencesCard.jsx';
import ProfileCard from '../components/ProfileCard.jsx';
@@ -95,6 +96,8 @@ export default function SettingsPage() {
<BuildVersionCard/>
<ChangePasswordCard/>
<DeleteAccountCard email={profile?.email}/>
</div>
);
+11
View File
@@ -9,3 +9,14 @@ export const deleteAccount = (password) =>
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password}),
});
/**
* POST /api/auth/password on Auth: 204 (the other logins end, this one stays), 403 INVALID_PASSWORD, 429 with
* Retry-After in seconds.
*/
export const changePassword = (currentPassword, newPassword) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/auth/password`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({currentPassword, newPassword}),
});
+6
View File
@@ -42,6 +42,12 @@ export const deleteUnconfirmedUser = (userId) =>
export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'});
/**
* Mails the user a link to choose a new password: 204, or 409 NOT_ACTIVE, SELF_ACTION or TARGET_IS_ADMIN.
*/
export const sendPasswordReset = (userId) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/password-reset`, {method: 'POST'});
/**
* "View as": new cookies that act with the role; null goes back to the own role. Under /api/auth, so it needs the
* refresh cookie and no access token; 403 ROLE_NOT_ALLOWED above the own role.
+8 -1
View File
@@ -30,8 +30,9 @@ export const registrationSchema = z.object({
displayName: displayNameField,
});
export const confirmEmailSchema = z
const newPasswordSchema = (fields = {}) => z
.object({
...fields,
password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
})
@@ -40,6 +41,12 @@ export const confirmEmailSchema = z
message: 'VALIDATION.PASSWORD_MISMATCH',
});
export const confirmEmailSchema = newPasswordSchema();
export const changePasswordSchema = newPasswordSchema({
currentPassword: z.string().min(1, 'VALIDATION.CURRENT_PASSWORD_REQUIRED'),
});
export const settingsSchema = z.object({
displayName: displayNameField,
});
+20 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
import {changePasswordSchema, confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
const settings = () => ({useDarkTheme: true, language: 'de'});
@@ -46,6 +46,25 @@ describe('confirm page password', () => {
});
});
describe('changePasswordSchema', () => {
const valid = {currentPassword: 'old', password: 'password1', passwordConfirmation: 'password1'};
it('takes a current password and a good new one', () => {
expect(changePasswordSchema.safeParse(valid).success).toBe(true);
});
it('requires the current password', () => {
expect(changePasswordSchema.safeParse({...valid, currentPassword: ''}).success).toBe(false);
});
it('applies the rules of the new password and the repeat', () => {
expect(changePasswordSchema.safeParse({...valid, password: 'short', passwordConfirmation: 'short'}).success).toBe(false);
expect(changePasswordSchema.safeParse({...valid, passwordConfirmation: 'password2'}).success).toBe(false);
const long = 'é'.repeat(37);
expect(changePasswordSchema.safeParse({...valid, password: long, passwordConfirmation: long}).success).toBe(false);
});
});
describe('registration', () => {
it('has no password field', () => {
expect(registrationSchema.parse({email: 'a@b.de', password: 'password1'})).not.toHaveProperty('password');