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

+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>
</>;
}