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

+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>
);