Public Access
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:
1 parent
f71e8f7aff
commit
6627a8db66
19 files changed
+565
-25
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
Reference in new issue
Block a user